feat(cli-mode): add Preview-screen compose box for passthrough sessions (#989)

In the kanban Preview tab, render a small kandev-controlled composer below
the PassthroughTerminal when the session is is_passthrough. Submitting (Enter
or click) posts message.add over WS — the backend's Executor.Prompt routes
passthrough sessions to PTY stdin so the CLI agent actually receives it.
Shift+Enter inserts a newline; empty/whitespace input is disabled.

This closes #989 together with the previous backend commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kandev Agent
2026-05-23 15:45:20 +01:00
parent 85edb4546b
commit 8aab100d5e
2 changed files with 162 additions and 4 deletions
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, fireEvent, screen, cleanup, waitFor } from "@testing-library/react";
import { PassthroughComposer } from "./preview-session-tabs";
const TEXTAREA_TID = "passthrough-composer-textarea";
const SUBMIT_TID = "passthrough-composer-submit";
describe("PassthroughComposer", () => {
afterEach(() => {
cleanup();
});
it("submits trimmed text on Enter and clears the textarea", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: " hello agent " } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith("hello agent");
await waitFor(() => expect(textarea.value).toBe(""));
});
it("submits when the send button is clicked", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
fireEvent.change(screen.getByTestId(TEXTAREA_TID), {
target: { value: "ping" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TID));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("ping"));
});
it("Shift+Enter inserts a newline and does not submit", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID);
fireEvent.change(textarea, { target: { value: "line1" } });
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
});
it("does not submit empty or whitespace-only input", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID);
fireEvent.keyDown(textarea, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.change(textarea, { target: { value: " \n " } });
fireEvent.keyDown(textarea, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement;
expect(submitBtn.disabled).toBe(true);
});
it("disables the composer while a submission is in flight", async () => {
let resolveSubmit!: () => void;
const submitPromise = new Promise<void>((resolve) => {
resolveSubmit = resolve;
});
const onSubmit = vi.fn().mockReturnValue(submitPromise);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement;
fireEvent.change(textarea, { target: { value: "hi" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect(textarea.disabled).toBe(true));
expect(submitBtn.disabled).toBe(true);
resolveSubmit();
await waitFor(() => expect(textarea.disabled).toBe(false));
expect(textarea.value).toBe("");
});
});
@@ -1,8 +1,9 @@
"use client";
import { useCallback, useMemo } from "react";
import { IconLoader2 } from "@tabler/icons-react";
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import { IconLoader2, IconSend } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { AgentLogo } from "@/components/agent-logo";
import { GridSpinner } from "@/components/grid-spinner";
import { SessionTabs, type SessionTab } from "@/components/session-tabs";
@@ -163,8 +164,11 @@ function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId:
if (session.is_passthrough) {
return (
<div className="h-full bg-card">
<PassthroughTerminal sessionId={session.id} mode="agent" />
<div className="flex h-full flex-col bg-card">
<div className="flex-1 min-h-0">
<PassthroughTerminal sessionId={session.id} mode="agent" />
</div>
<PassthroughComposer onSubmit={handleSendMessage} />
</div>
);
}
@@ -176,6 +180,75 @@ function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId:
);
}
/**
* PassthroughComposer is the kandev-controlled compose box rendered alongside
* the PTY in passthrough mode. Submitting forwards the typed text via the
* onSubmit prop (which posts `message.add` over WS); the backend's
* `Executor.Prompt` routes passthrough sessions to PTY stdin so the CLI agent
* actually receives it. Enter submits; Shift+Enter inserts a newline.
*/
export function PassthroughComposer({
onSubmit,
}: {
onSubmit: (content: string) => Promise<void>;
}) {
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const trimmed = value.trim();
const canSubmit = trimmed.length > 0 && !isSending;
const submit = useCallback(async () => {
if (!canSubmit) return;
setIsSending(true);
try {
await onSubmit(trimmed);
setValue("");
} finally {
setIsSending(false);
}
}, [canSubmit, onSubmit, trimmed]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submit();
}
},
[submit],
);
return (
<div
className="flex flex-shrink-0 items-end gap-2 border-t bg-card px-2 py-2"
data-testid="passthrough-composer"
>
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message the CLI agent (Enter to send, Shift+Enter for newline)"
rows={1}
disabled={isSending}
className="min-h-9 max-h-32 flex-1 resize-none"
data-testid="passthrough-composer-textarea"
/>
<Button
type="button"
size="sm"
variant="default"
onClick={() => void submit()}
disabled={!canSubmit}
className="cursor-pointer h-9 shrink-0"
data-testid="passthrough-composer-submit"
aria-label="Send message to CLI agent"
>
<IconSend className="h-4 w-4" />
</Button>
</div>
);
}
function RunningSpinner() {
return <GridSpinner className="text-muted-foreground shrink-0 text-[12px]" />;
}