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:
@@ -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]" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user