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; /** * Open the session menu above the button instead of below. The dock puts * this at its foot, and the dock clips its own overflow, so a downward menu * there is drawn outside the panel and never seen. */ dropUp?: boolean; /** Fill the row. The dock's send bar is the width of the dock. */ fullWidth?: boolean; } /** * 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, dropUp = false, fullWidth = false, }: Props) { const { sessions, sendInput } = useTerminal(); const { projects, setActiveTabKey, requestTerminalFocus, pushToast } = useAppState( useShallow((s) => ({ projects: s.projects, setActiveTabKey: s.setActiveTabKey, requestTerminalFocus: s.requestTerminalFocus, 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 unavailable = 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)); // Switching tabs is not the same as taking focus, and when the dock is // open beside the terminal it just sent to, that tab is already the // active one — so nothing above moves the caret off this button. The // note is sitting in the prompt waiting for Enter; put the user there. requestTerminalFocus(sessionId); } catch (e) { pushToast({ kind: "error", message: "Could not send the note to the agent", detail: String(e), }); } }, [body, sendInput, setActiveTabKey, requestTerminalFocus, 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) => ( ))}
)}
); }