From f79a44e0a8604fbd36cb39b771009297c88293c8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 1 Sep 2026 13:02:22 -0700 Subject: [PATCH] Add the send-to-agent button --- .../notes/SendToAgentButton.test.tsx | 105 ++++++++++++++ .../components/notes/SendToAgentButton.tsx | 137 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 app/src/components/notes/SendToAgentButton.test.tsx create mode 100644 app/src/components/notes/SendToAgentButton.tsx diff --git a/app/src/components/notes/SendToAgentButton.test.tsx b/app/src/components/notes/SendToAgentButton.test.tsx new file mode 100644 index 0000000..884f915 --- /dev/null +++ b/app/src/components/notes/SendToAgentButton.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import SendToAgentButton from "./SendToAgentButton"; +import type { Project, TerminalSession } from "../../lib/types"; + +const sendInput = vi.fn(async () => {}); +let sessions: TerminalSession[] = []; + +vi.mock("../../hooks/useTerminal", () => ({ + useTerminal: () => ({ sessions, sendInput }), +})); + +const setActiveTabKey = vi.fn(); +const pushToast = vi.fn(); +let projects: Project[] = []; + +vi.mock("../../store/appState", () => ({ + useAppState: Object.assign( + (selector: (s: unknown) => unknown) => + selector({ projects, setActiveTabKey, pushToast }), + { getState: () => ({ projects, setActiveTabKey, pushToast }) }, + ), + terminalTabKey: (id: string) => `term:${id}`, +})); + +const session = (over: Partial = {}): TerminalSession => ({ + id: "s1", + projectId: "p1", + projectName: "api", + sessionType: "claude", + sessionName: null, + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + sessions = []; + projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project]; +}); + +describe("SendToAgentButton", () => { + it("is disabled when the project has no running session", () => { + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); + + it("is disabled when the only session belongs to another project", () => { + sessions = [session({ projectId: "other" })]; + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); + + it("is disabled when the only session is a bash tab", () => { + // `bash -l`'s readline has no binding for ESC+CR and just bells, so a + // shell is never a target. + sessions = [session({ sessionType: "bash" })]; + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); + + it("sends straight to the one session, with newlines converted and no terminator", async () => { + sessions = [session()]; + render(); + + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + + await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo")); + expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false); + }); + + it("focuses the terminal it sent to, so the user watches it land", async () => { + sessions = [session()]; + render(); + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1")); + }); + + it("offers a menu of display names when several sessions are open", async () => { + sessions = [session(), session({ id: "s2", sessionName: "review" })]; + projects = [ + { id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project, + ]; + render(); + + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + expect(sendInput).not.toHaveBeenCalled(); + + fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" })); + await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi")); + }); + + it("reports a failed send rather than looking like it worked", async () => { + sessions = [session()]; + sendInput.mockRejectedValueOnce(new Error("session closed")); + render(); + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + await waitFor(() => expect(pushToast).toHaveBeenCalled()); + }); + + it("does nothing for an empty note", () => { + sessions = [session()]; + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); +}); diff --git a/app/src/components/notes/SendToAgentButton.tsx b/app/src/components/notes/SendToAgentButton.tsx new file mode 100644 index 0000000..9868485 --- /dev/null +++ b/app/src/components/notes/SendToAgentButton.tsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useTerminal } from "../../hooks/useTerminal"; +import { useAppState, terminalTabKey } from "../../store/appState"; +import { toClaudePayload } from "../../lib/claudeInput"; +import { sessionDisplayName } from "../../lib/sessionName"; +import Button from "../ui/Button"; + +interface Props { + projectId: string; + body: string; +} + +/** + * Puts a note into a running Claude session's prompt. + * + * Three behaviours by target count: none disables the button, one sends + * straight there, several ask which. It never guesses — the note goes to a + * session the user named, or to the only one there is. + * + * Only `claude` sessions are offered. A bash tab would receive ESC+CR as an + * unbound readline key and answer with a bell (see `lib/claudeInput.ts`). + */ +export default function SendToAgentButton({ projectId, body }: Props) { + const { sessions, sendInput } = useTerminal(); + const { projects, setActiveTabKey, pushToast } = useAppState( + useShallow((s) => ({ + projects: s.projects, + setActiveTabKey: s.setActiveTabKey, + pushToast: s.pushToast, + })), + ); + const [menuOpen, setMenuOpen] = useState(false); + const rootRef = useRef(null); + + const targets = useMemo( + () => + sessions.filter( + (s) => s.projectId === projectId && s.sessionType === "claude", + ), + [sessions, projectId], + ); + + const project = projects.find((p) => p.id === projectId); + const hasBody = body.trim().length > 0; + const disabled = targets.length === 0 || !hasBody; + + // Same dismissal contract as `ui/OverflowMenu` and the tab context menu. + useEffect(() => { + if (!menuOpen) return; + const onDocClick = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [menuOpen]); + + const send = useCallback( + async (sessionId: string) => { + setMenuOpen(false); + try { + // No trailing CR: the note lands in the prompt and the user presses + // Enter. Newlines become ESC+CR so it arrives as one message rather + // than one prompt per line. + await sendInput(sessionId, toClaudePayload(body)); + // A courtesy, not part of the send: if the tab cannot be focused the + // text still went. + setActiveTabKey(terminalTabKey(sessionId)); + } catch (e) { + pushToast({ + kind: "error", + message: "Could not send the note to the agent", + detail: String(e), + }); + } + }, + [body, sendInput, setActiveTabKey, pushToast], + ); + + const onClick = useCallback(() => { + // The target is resolved at click time and pinned for the whole send, the + // hazard `useSTT` guards against by capturing its session at record start: + // the list can change while the request is in flight. + if (targets.length === 1) { + void send(targets[0].id); + return; + } + setMenuOpen((open) => !open); + }, [targets, send]); + + const title = !hasBody + ? "Nothing to send — this note is empty" + : targets.length === 0 + ? "No running Claude session for this project" + : "Put this note into the agent's prompt (you press Enter)"; + + return ( +
+ + {menuOpen && targets.length > 1 && ( +
+ {targets.map((s) => ( + + ))} +
+ )} +
+ ); +}