"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 ? (

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 ( ); } 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

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.codeContent && (
          {comment.codeContent}
        
)}