Add a per-project Notes tab with a send-to-agent action #48
@@ -11,14 +11,22 @@ vi.mock("../../hooks/useTerminal", () => ({
|
||||
}));
|
||||
|
||||
const setActiveTabKey = vi.fn();
|
||||
const requestTerminalFocus = 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 }) },
|
||||
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
|
||||
{
|
||||
getState: () => ({
|
||||
projects,
|
||||
setActiveTabKey,
|
||||
requestTerminalFocus,
|
||||
pushToast,
|
||||
}),
|
||||
},
|
||||
),
|
||||
terminalTabKey: (id: string) => `term:${id}`,
|
||||
}));
|
||||
@@ -149,4 +157,35 @@ describe("SendToAgentButton", () => {
|
||||
// overflow, so a downward menu at the bottom edge is invisible.
|
||||
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
||||
});
|
||||
|
||||
// Switching to the tab is not enough. When the dock is open beside the
|
||||
// terminal it sends to, that terminal is already the active tab, so
|
||||
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
|
||||
// this button, one click short of the Enter the user came to press.
|
||||
it("hands focus to the terminal so the next keystroke is Enter", async () => {
|
||||
sessions = [session()];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
|
||||
});
|
||||
|
||||
it("leaves focus alone when the send failed", async () => {
|
||||
sessions = [session()];
|
||||
sendInput.mockRejectedValueOnce(new Error("pty gone"));
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||
expect(requestTerminalFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("focuses the session picked from the menu, not the first one", async () => {
|
||||
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||
|
||||
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
|
||||
});
|
||||
});
|
||||
@@ -36,13 +36,15 @@ export default function SendToAgentButton({
|
||||
fullWidth = false,
|
||||
}: Props) {
|
||||
const { sessions, sendInput } = useTerminal();
|
||||
const { projects, setActiveTabKey, pushToast } = useAppState(
|
||||
useShallow((s) => ({
|
||||
projects: s.projects,
|
||||
setActiveTabKey: s.setActiveTabKey,
|
||||
pushToast: s.pushToast,
|
||||
})),
|
||||
);
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
@@ -86,6 +88,11 @@ export default function SendToAgentButton({
|
||||
// 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",
|
||||
@@ -94,7 +101,7 @@ export default function SendToAgentButton({
|
||||
});
|
||||
}
|
||||
},
|
||||
[body, sendInput, setActiveTabKey, pushToast],
|
||||
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
|
||||
);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
|
||||
@@ -538,3 +538,59 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — focus on request", () => {
|
||||
/** Mount, then deliberately give focus away, so what the assertions below
|
||||
* observe is the *request* taking effect and never the focus `active`
|
||||
* already grants on mount. That distinction is the whole point: the notes
|
||||
* dock sends to a terminal whose tab is already active, where nothing
|
||||
* changes and no `active` effect re-runs. */
|
||||
async function mountAndBlur() {
|
||||
const view = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const elsewhere = document.createElement("button");
|
||||
document.body.appendChild(elsewhere);
|
||||
elsewhere.focus();
|
||||
expect(document.activeElement).toBe(elsewhere);
|
||||
return view;
|
||||
}
|
||||
|
||||
it("focuses the terminal named by the request", async () => {
|
||||
const view = await mountAndBlur();
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||
});
|
||||
|
||||
it("ignores a request meant for another session", async () => {
|
||||
const view = await mountAndBlur();
|
||||
const before = document.activeElement;
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s2");
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(before);
|
||||
expect(document.activeElement).not.toBe(helperTextarea(view.container));
|
||||
});
|
||||
|
||||
it("clears the request, so a second send focuses again", async () => {
|
||||
const view = await mountAndBlur();
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
});
|
||||
expect(useAppState.getState().pendingTerminalFocus).toBeNull();
|
||||
|
||||
const elsewhere = document.querySelector("button");
|
||||
(elsewhere as HTMLButtonElement).focus();
|
||||
|
||||
await act(async () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
});
|
||||
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||
});
|
||||
});
|
||||
@@ -731,6 +731,21 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
}, [active, gpuRenderingSetting]);
|
||||
|
||||
// Focus on demand, for the caller that cannot rely on the effect above.
|
||||
// That one keys off `active`, so it covers switching *to* a terminal and
|
||||
// nothing else — and the notes dock sends to the terminal already on screen,
|
||||
// where `active` never changes. Consumed once and cleared, so asking twice
|
||||
// for the same terminal works.
|
||||
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
|
||||
const clearPendingTerminalFocus = useAppState(
|
||||
(s) => s.clearPendingTerminalFocus,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (pendingTerminalFocus !== sessionId) return;
|
||||
termRef.current?.focus();
|
||||
clearPendingTerminalFocus();
|
||||
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
|
||||
|
||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||
// pulling it out from under them costs them the only route to finishing a
|
||||
|
||||
@@ -112,3 +112,27 @@ describe("toasts", () => {
|
||||
expect(toasts()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal focus requests", () => {
|
||||
beforeEach(() => useAppState.setState({ pendingTerminalFocus: null }));
|
||||
|
||||
const pending = () => useAppState.getState().pendingTerminalFocus;
|
||||
|
||||
it("names the session that should take focus", () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
expect(pending()).toBe("s1");
|
||||
});
|
||||
|
||||
// Consumed once, exactly like `pendingHomeTab`. Without the clear, the
|
||||
// second send to a terminal already holding the request would set the same
|
||||
// value, no state would change, and no effect would re-run — which is the
|
||||
// failure this whole mechanism exists to fix.
|
||||
it("is cleared once consumed, so the same terminal can be asked again", () => {
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
useAppState.getState().clearPendingTerminalFocus();
|
||||
expect(pending()).toBeNull();
|
||||
|
||||
useAppState.getState().requestTerminalFocus("s1");
|
||||
expect(pending()).toBe("s1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,6 +144,21 @@ interface AppState {
|
||||
/** Consumed once by `ProjectHome`, then cleared. */
|
||||
pendingHomeTab: { projectId: string; tab: string } | null;
|
||||
clearPendingHomeTab: () => void;
|
||||
/**
|
||||
* Ask a terminal to take keyboard focus.
|
||||
*
|
||||
* `TerminalView` already focuses when its tab *becomes* active, which covers
|
||||
* switching to a terminal. It cannot cover being asked to focus the terminal
|
||||
* that is already on screen — nothing changes, so no effect re-runs — and
|
||||
* that is the ordinary case for the notes dock, which sits beside the
|
||||
* terminal it sends to.
|
||||
*
|
||||
* Consumed once and cleared, like `pendingHomeTab`: holding the id would
|
||||
* make a second request for the same terminal a no-op state write.
|
||||
*/
|
||||
pendingTerminalFocus: string | null;
|
||||
requestTerminalFocus: (sessionId: string) => void;
|
||||
clearPendingTerminalFocus: () => void;
|
||||
closeHomeTab: (projectId: string) => void;
|
||||
setActiveTabKey: (key: string) => void;
|
||||
cycleTab: (delta: number) => void;
|
||||
@@ -352,6 +367,9 @@ export const useAppState = create<AppState>((set) => ({
|
||||
}),
|
||||
pendingHomeTab: null,
|
||||
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
||||
pendingTerminalFocus: null,
|
||||
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
|
||||
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
|
||||
closeHomeTab: (projectId) =>
|
||||
set((state) => {
|
||||
const key = homeTabKey(projectId);
|
||||
|
||||
Reference in New Issue
Block a user