diff --git a/apps/web/components/task/preview-session-tabs.test.tsx b/apps/web/components/task/preview-session-tabs.test.tsx new file mode 100644 index 0000000000..eb99e7f336 --- /dev/null +++ b/apps/web/components/task/preview-session-tabs.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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((resolve) => { + resolveSubmit = resolve; + }); + const onSubmit = vi.fn().mockReturnValue(submitPromise); + + render(); + 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(""); + }); +}); diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index 25845d19a7..06eb00f3ed 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -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 ( -
- +
+
+ +
+
); } @@ -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; +}) { + 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) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void submit(); + } + }, + [submit], + ); + + return ( +
+