"use client";
import { useCallback, useMemo, useState } from "react";
import {
IconArrowRight,
IconMessageCircle,
IconMessageDots,
IconSend,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import { PRStatusChip } from "@/components/github/pr-status-chip";
import { PassthroughComposer } from "./passthrough-composer";
import { PRMergedBanner } from "./chat/chat-input-area";
import { useAppStore } from "@/components/state-provider";
import { useNextWorkflowStep } from "@/hooks/domains/kanban/use-plan-actions";
import { usePendingDiffCommentsByFile } from "@/hooks/domains/comments/use-diff-comments";
import { useCommentsStore } from "@/lib/state/slices/comments/comments-store";
import { useFileEditors } from "@/hooks/use-file-editors";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format";
import type { DiffComment } from "@/lib/diff/types";
import { getWebSocketClient } from "@/lib/ws/connection";
import { PassthroughTerminal } from "./passthrough-terminal";
/**
* PassthroughToolbar wraps the PTY terminal with the kandev surface that the
* full ACP `ChatStatusBar` + `ChatInputArea` provide for chat mode: PR status,
* merge banner, "Move to next step" workflow advancement, a collapsible Chat
* compose box, and a collapsible Comments panel showing pending review comments
* (each editable inline with a clickable file reference).
*
* Default focus stays on the xterm terminal — both Chat and Comments are
* collapsed behind toolbar buttons so the user can keep raw-terminal
* interaction front-and-centre and only opt in when they want to compose a
* follow-up or review what's about to be attached. Ctrl-C in the xterm
* cancels the agent, so no dedicated Stop button is needed.
*/
export function PassthroughToolbar({
sessionId,
taskId,
}: {
sessionId: string | null | undefined;
taskId: string | null;
}) {
const [composerOpen, setComposerOpen] = useState(false);
const [commentsOpenState, setCommentsOpen] = useState(false);
const sessionState = useAppStore((state) =>
sessionId ? (state.taskSessions.items[sessionId]?.state ?? null) : null,
);
const isAgentBusy = sessionState === "RUNNING" || sessionState === "STARTING";
const nextStep = useNextWorkflowStep(taskId);
const showProceed = !!nextStep.proceedStepName && !isAgentBusy;
const { pendingComments, pendingCount } = usePendingPassthroughComments(sessionId);
const { isMobile } = useResponsiveBreakpoint();
const { openFile } = useFileEditors();
// Derive open state: auto-close when pending comments are cleared
const commentsOpen = commentsOpenState && pendingCount > 0;
const handleSendMessage = useSendPassthroughMessage({
taskId,
sessionId,
pendingComments,
onSent: () => {
setComposerOpen(false);
setCommentsOpen(false);
},
});
return (
{commentsOpen && pendingCount > 0 && (
handleSendMessage("")}
/>
)}
{composerOpen && (
setComposerOpen(false)}
autoFocus
placeholder="Type a message, Shift+Enter for newline, Esc to close"
header={pendingCount > 0 ? : null}
/>
)}
setComposerOpen((open) => !open)}
commentsOpen={commentsOpen}
onToggleComments={() => setCommentsOpen((open) => !open)}
pendingCommentsCount={pendingCount}
/>
);
}
function flattenComments(byFile: Record): DiffComment[] {
const all: DiffComment[] = [];
for (const list of Object.values(byFile)) all.push(...list);
return all;
}
function usePendingPassthroughComments(sessionId: string | null | undefined) {
const pendingCommentsByFile = usePendingDiffCommentsByFile(sessionId ?? null);
const pendingComments = useMemo(
() => flattenComments(pendingCommentsByFile),
[pendingCommentsByFile],
);
return { pendingComments, pendingCount: pendingComments.length };
}
function useSendPassthroughMessage({
taskId,
sessionId,
pendingComments,
onSent,
}: {
taskId: string | null;
sessionId: string | null | undefined;
pendingComments: DiffComment[];
onSent: () => void;
}) {
const { toast } = useToast();
const markCommentsSent = useCommentsStore((s) => s.markCommentsSent);
return useCallback(
async (content: string) => {
if (!taskId || !sessionId) {
toast({ title: "Session not ready", variant: "error" });
throw new Error("Session not ready");
}
const client = getWebSocketClient();
if (!client) {
toast({ title: "Not connected — please reload to retry", variant: "error" });
throw new Error("WebSocket client not available");
}
// Format pending review comments as markdown and prepend so they reach
// the agent's stdin alongside the user's typed prompt. Mark them sent
// only after the WS request resolves so a failed send doesn't drop the
// user's queued comments.
const formatted =
pendingComments.length > 0
? formatReviewCommentsAsMarkdown(pendingComments) + content
: content;
try {
await client.request(
"message.add",
{ task_id: taskId, session_id: sessionId, content: formatted },
10_000,
);
if (pendingComments.length > 0) {
markCommentsSent(pendingComments.map((c) => c.id));
}
onSent();
} catch (error) {
console.error("Failed to send passthrough message:", error);
toast({ title: "Failed to send message", variant: "error" });
throw error;
}
},
[taskId, sessionId, toast, pendingComments, markCommentsSent, onSent],
);
}
function ChatToggleButton({
composerOpen,
onToggle,
}: {
composerOpen: boolean;
onToggle: () => void;
}) {
return (
{composerOpen ? (
) : (
)}
Chat
{composerOpen ? (
Close the compose box (or press Esc inside it). The CLI agent terminal keeps
focus.
) : (
Open a kandev-controlled compose box above the terminal to type a follow-up message.
Press Enter to send, Shift+Enter for a newline.
The text is delivered straight to the CLI agent's stdin — pending review comments
(if any) are prepended automatically.
)}
);
}
function commentsToggleClassName(count: number, commentsOpen: boolean): string {
// Vivid amber when there are pending comments so the user sees "something
// to do" — washes back to plain outline once they're cleared / sent.
if (count === 0) return "h-6 gap-1 px-2.5 text-xs cursor-pointer";
if (commentsOpen) return "h-6 gap-1 px-2.5 text-xs cursor-pointer";
return "h-6 gap-1 px-2.5 text-xs cursor-pointer border-amber-500/60 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-700 dark:text-amber-300 dark:hover:text-amber-200";
}
function CommentsToggleButton({
commentsOpen,
onToggle,
pendingCommentsCount,
}: {
commentsOpen: boolean;
onToggle: () => void;
pendingCommentsCount: number;
}) {
const disabled = pendingCommentsCount === 0;
return (
{commentsOpen ? (
) : (
)}
Comments
{pendingCommentsCount > 0 && (
{pendingCommentsCount}
)}
);
}
function CommentsTooltipBody({ commentsOpen, count }: { commentsOpen: boolean; count: number }) {
if (count === 0) {
return (
No pending review comments.
Add a comment from the diff or file view, then come back here to review and send it.
);
}
if (commentsOpen) {
return (
Hide the comment list. The comments stay queued and will still be sent next time you submit
(here or from the chat box).
);
}
const plural = count === 1 ? "" : "s";
return (
{count} pending review comment{plural}. Click to expand the list — you can edit each
comment, click the file path to jump to the source, or remove a comment with the trash icon.
Hit Send to agent inside the panel to deliver them to the CLI agent right
away, or just open the chat box and type a follow-up — the comments will be prepended.
);
}
function PendingCommentsHint({ count }: { count: number }) {
return (
{count} pending review comment{count === 1 ? "" : "s"} will be attached to this message.
);
}
function CommentsPanel({
comments,
openFile,
onSend,
}: {
comments: DiffComment[];
openFile: (path: string) => void;
onSend: () => Promise | void;
}) {
const [isSending, setIsSending] = useState(false);
const handleSend = useCallback(async () => {
if (isSending) return;
setIsSending(true);
try {
await onSend();
} catch {
// onSend already toasted the error; keep panel open so the user can retry.
} finally {
setIsSending(false);
}
}, [isSending, onSend]);
const count = comments.length;
const plural = count === 1 ? "" : "s";
return (
{count} review comment{plural} ready to send
Send to agent
Deliver these comments to the CLI agent's stdin as a single message — no need to
open the chat box. The agent receives the same markdown that prepending to a typed
message would produce.
{comments.map((comment) => (
))}
);
}
function formatLineRange(comment: DiffComment): string {
return comment.startLine === comment.endLine
? `${comment.startLine}`
: `${comment.startLine}-${comment.endLine}`;
}
function CommentCard({
comment,
openFile,
}: {
comment: DiffComment;
openFile: (path: string) => void;
}) {
const updateComment = useCommentsStore((s) => s.updateComment);
const removeComment = useCommentsStore((s) => s.removeComment);
const lineRange = formatLineRange(comment);
const handleOpenFile = useCallback(() => {
openFile(comment.filePath);
}, [openFile, comment.filePath]);
return (
{comment.filePath}:{lineRange}
removeComment(comment.id)}
className="rounded p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive cursor-pointer"
aria-label="Remove comment"
data-testid="passthrough-comment-remove"
>
{comment.codeContent && (
{comment.codeContent}
)}
);
}
type StatusRowProps = {
taskId: string | null;
nextStepName: string | null;
onProceed: () => void;
isMoving: boolean;
showProceed: boolean;
composerOpen: boolean;
onToggleComposer: () => void;
commentsOpen: boolean;
onToggleComments: () => void;
pendingCommentsCount: number;
};
function PassthroughStatusRow({
taskId,
nextStepName,
onProceed,
isMoving,
showProceed,
composerOpen,
onToggleComposer,
commentsOpen,
onToggleComments,
pendingCommentsCount,
}: StatusRowProps) {
return (
{taskId &&
}
{showProceed && nextStepName && (
{nextStepName}
Move task to the next workflow step
)}
);
}