Add the send-to-agent button

This commit is contained in:
2026-09-01 13:02:22 -07:00
parent 5a8e24ccbe
commit f79a44e0a8
2 changed files with 242 additions and 0 deletions
@@ -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> = {}): 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(<SendToAgentButton projectId="p1" body="hello" />);
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(<SendToAgentButton projectId="p1" body="hello" />);
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(<SendToAgentButton projectId="p1" body="hello" />);
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(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
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(<SendToAgentButton projectId="p1" body="hi" />);
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(<SendToAgentButton projectId="p1" body="hi" />);
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(<SendToAgentButton projectId="p1" body="hi" />);
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(<SendToAgentButton projectId="p1" body=" " />);
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
});
});
@@ -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<HTMLDivElement>(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 (
<div ref={rootRef} className="relative inline-block">
<Button
variant="secondary"
disabled={disabled}
onClick={onClick}
aria-haspopup={targets.length > 1 ? "menu" : undefined}
aria-expanded={targets.length > 1 ? menuOpen : undefined}
title={title}
>
Send to agent
</Button>
{menuOpen && targets.length > 1 && (
<div
role="menu"
className="absolute right-0 z-40 mt-1 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs"
style={{ boxShadow: "var(--shadow-overlay)" }}
>
{targets.map((s) => (
<button
key={s.id}
type="button"
role="menuitem"
onClick={() => void send(s.id)}
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
{sessionDisplayName(s, project)}
</button>
))}
</div>
)}
</div>
);
}