fix: preserve passthrough Escape in Quick Chat (#3109) (#3119)

This commit is contained in:
Carlos Florêncio
2026-08-28 15:51:18 +00:00
committed by GitHub
parent 2c293b55b0
commit 1744c8b830
16 changed files with 672 additions and 65 deletions
+16 -2
View File
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => {
push: vi.fn(),
quickChat: vi.fn(),
quickChatLauncherCalls: [] as unknown[][],
keyboardShortcutCalls: [] as unknown[][],
setTheme: vi.fn(),
};
});
@@ -42,7 +43,9 @@ vi.mock("@/hooks/use-app-destinations", () => ({
useStaticDestinations: () => mocks.destinations.map((destination) => ({ ...destination })),
}));
vi.mock("@/hooks/use-app-shortcuts", () => ({ useAppShortcuts: vi.fn() }));
vi.mock("@/hooks/use-keyboard-shortcut", () => ({ useKeyboardShortcut: vi.fn() }));
vi.mock("@/hooks/use-keyboard-shortcut", () => ({
useKeyboardShortcut: (...args: unknown[]) => mocks.keyboardShortcutCalls.push(args),
}));
vi.mock("@/hooks/use-plugin-shortcuts", () => ({ usePluginShortcuts: vi.fn() }));
vi.mock("@/hooks/use-quick-chat-launcher", () => ({
useQuickChatLauncher: (...args: unknown[]) => {
@@ -72,6 +75,7 @@ function navigationCommand(): CommandItem {
beforeEach(async () => {
mocks.commands = [];
mocks.quickChatLauncherCalls = [];
mocks.keyboardShortcutCalls = [];
mocks.push.mockReset();
await activateLocale(DEFAULT_LOCALE);
});
@@ -101,11 +105,21 @@ describe("GlobalCommands navigation commands", () => {
render(<GlobalCommands />);
expect(mocks.quickChatLauncherCalls).toEqual([
["workspace-1", "chat", { silentFocusReturn: false }],
["workspace-1", "chat", { silentFocusReturn: false, toggleWhenOpen: true }],
["workspace-1", "config", { silentFocusReturn: false }],
]);
});
it("@covers AC-UI-QUICK-TERMINAL-001.10 captures the toggle before xterm can consume it", () => {
render(<GlobalCommands />);
expect(mocks.keyboardShortcutCalls).toHaveLength(1);
expect(mocks.keyboardShortcutCalls[0]?.[2]).toEqual({
capture: true,
stopPropagation: true,
});
});
// Drives a real locale switch rather than a stubbed `t`. The point of the
// test is that the memo key tracks translated values, and only the real
// catalog can show that end to end — a hand-written translation table proves
+8 -3
View File
@@ -118,6 +118,7 @@ export function GlobalCommands() {
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const handleOpenQuickChat = useQuickChatLauncher(activeWorkspaceId, "chat", {
silentFocusReturn: false,
toggleWhenOpen: true,
});
const handleOpenConfigChat = useQuickChatLauncher(activeWorkspaceId, "config", {
silentFocusReturn: false,
@@ -172,11 +173,15 @@ export function GlobalCommands() {
);
useRegisterCommands(commands);
useKeyboardShortcut(quickChatShortcut, handleOpenQuickChat);
// Order matters: useAppShortcuts (core) must register its capture-phase
// keydown listener before usePluginShortcuts so core shortcuts win when a
// combo matches both — see the precedence note on each hook.
// keydown listener before the Quick Chat toggle and plugin shortcuts. This
// preserves core precedence, while the toggle still runs before xterm can
// consume a configured shortcut.
useAppShortcuts();
useKeyboardShortcut(quickChatShortcut, handleOpenQuickChat, {
capture: true,
stopPropagation: true,
});
usePluginShortcuts();
return <SettingsDiscoveryCommands />;
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureQuickChatLauncherFocus, restoreQuickChatLauncherFocus } from "./quick-chat-focus";
import {
captureQuickChatLauncherFocus,
registerQuickChatCloseHandler,
requestQuickChatClose,
restoreQuickChatLauncherFocus,
} from "./quick-chat-focus";
const SILENT_FOCUS_ATTRIBUTE = "data-quick-chat-silent-focus";
@@ -9,6 +14,17 @@ afterEach(() => {
});
describe("quick chat launcher focus", () => {
it("routes an external close request through the registered modal lifecycle", () => {
const close = vi.fn();
const unregister = registerQuickChatCloseHandler(close);
expect(requestQuickChatClose()).toBe(true);
expect(close).toHaveBeenCalledTimes(1);
unregister();
expect(requestQuickChatClose()).toBe(false);
});
// @covers AC-UI-QUICK-TERMINAL-001.9
it("restores focus through the animation frame after closing", () => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
@@ -1,5 +1,6 @@
let launcherFocus: HTMLElement | null = null;
let launcherFocusShouldBeSilent = false;
let quickChatCloseHandler: (() => void) | null = null;
const SILENT_FOCUS_ATTRIBUTE = "data-quick-chat-silent-focus";
function markFocusAsSilent(element: HTMLElement): void {
@@ -30,3 +31,19 @@ export function restoreQuickChatLauncherFocus(): void {
element.focus();
});
}
/** Registers the mounted modal's close lifecycle for global launchers. */
export function registerQuickChatCloseHandler(handler: () => void): () => void {
quickChatCloseHandler = handler;
return () => {
if (quickChatCloseHandler === handler) quickChatCloseHandler = null;
};
}
/** Closes Quick Chat through its mounted modal lifecycle when available. */
export function requestQuickChatClose(): boolean {
const handler = quickChatCloseHandler;
if (!handler) return false;
handler();
return true;
}
@@ -36,6 +36,7 @@ vi.mock("@/lib/api/domains/quick-terminal-api", () => ({
}));
import { useAgentSelection, useQuickChatModal } from "./use-quick-chat-modal";
import { requestQuickChatClose } from "./quick-chat-focus";
import { getQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
const WORKSPACE_ID = "ws-1";
@@ -267,6 +268,38 @@ describe("useQuickChatModal — setup lifecycle", () => {
WORKSPACE_ID,
);
});
it("invalidates an in-flight agent start when an external launcher closes the modal", async () => {
let resolveStart!: (value: { task_id: string; session_id: string }) => void;
mockStartQuickChat.mockImplementationOnce(
() =>
new Promise<{ task_id: string; session_id: string }>((resolve) => {
resolveStart = resolve;
}),
);
const { result } = renderHook(() => useQuickChatModal(WORKSPACE_ID));
act(() => {
void result.current.handleSelectAgent("agent-a");
});
expect(result.current.pendingAgentId).toBe("agent-a");
expect(requestQuickChatClose()).toBe(true);
await act(async () => {
resolveStart({ task_id: "task-a", session_id: "sess-a" });
await flushPromises();
});
expect(mockAppState.openQuickChat).not.toHaveBeenCalledWith(
"sess-a",
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
);
expect(mockDeleteTask).toHaveBeenCalledWith("task-a");
});
});
describe("useQuickChatModal — persisted config lifecycle", () => {
@@ -11,6 +11,7 @@ import { ApiError } from "@/lib/api/client";
import { type PtyTerminalState } from "@/components/settings/pty-terminal-view";
import { isQuickChatSetupSessionId } from "@/lib/state/slices/ui/quick-chat-session";
import { persistQuickChatRename } from "@/lib/quick-chat/rename";
import { registerQuickChatCloseHandler } from "./quick-chat-focus";
import type { QuickChatSessionKind, QuickTerminalTab } from "@/lib/state/slices/ui/types";
import { useQuickChatCloseActions, resolveQuickChatTaskId } from "./use-quick-chat-close-actions";
import { useQuickChatTabOrder } from "./use-quick-chat-tab-order";
@@ -399,6 +400,11 @@ export function useQuickChatModal(workspaceId: string, onSupersedeConfigStart =
tabOrder: tabOrder.order,
setSetupKey,
});
const closeFromLauncher = useCallback(
() => tabActions.handleOpenChange(false),
[tabActions.handleOpenChange],
);
useEffect(() => registerQuickChatCloseHandler(closeFromLauncher), [closeFromLauncher]);
const handleSelectAgent = useCallback(
(agentId: string, repositories: QuickChatRepositoryInput[] = []) =>
+146 -48
View File
@@ -31,6 +31,7 @@ import { usePanelSearch } from "@/hooks/use-panel-search";
import { useTerminalBusyTracking } from "./use-terminal-busy-tracking";
import { useTerminalTheme } from "./use-terminal-theme";
import { useTranslation } from "react-i18next";
import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard";
type BaseProps = {
autoFocus?: boolean;
@@ -153,6 +154,26 @@ export function isSessionEnded(state: TaskSessionState | null | undefined): bool
return state != null && SESSION_TERMINAL_STATES.has(state);
}
export function shouldGuardPassthroughEscape(
event: KeyboardEvent,
textarea: HTMLTextAreaElement | undefined,
isConnected = true,
): boolean {
if (
!isConnected ||
event.key !== "Escape" ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return false;
}
const activeElement = typeof document === "undefined" ? null : document.activeElement;
// The activeElement fallback covers synthetic events whose target and focus diverge.
return event.target === textarea || activeElement === textarea;
}
/**
* Whether every session sharing this environment has ended.
*
@@ -209,37 +230,31 @@ function useEnvironmentEnded(environmentId: string | null | undefined): boolean
);
}
// eslint-disable-next-line max-lines-per-function -- wires many hooks + refs; each block is already its own hook
export function PassthroughTerminal(props: PassthroughTerminalProps) {
const {
mode,
label,
autoFocus,
pendingCommand,
onCommandSent,
onXtermReady,
disableWebgl,
manualInputRouting,
onWsReady,
enableTouchScroll,
} = props;
const { resolvedTheme } = useTheme();
const terminalId = mode === "shell" ? props.terminalId : undefined;
const environmentId = mode === "shell" ? props.environmentId : undefined;
const refs = useTerminalRefs();
const { terminalRef, xtermRef, fitAddonRef, wsRef, attachAddonRef } = refs;
type PassthroughConnectionOptions = {
connectionID: string | null | undefined;
autoFocus?: boolean;
onXtermReady?: (xterm: Terminal) => void;
xtermRef: React.MutableRefObject<Terminal | null>;
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
wsRef: React.MutableRefObject<WebSocket | null>;
};
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const environmentSessionId = useEnvironmentSessionId();
const sessionId = mode === "agent" ? (props.sessionId ?? storeSessionId) : environmentSessionId;
const { session } = useSession(sessionId);
const taskId = session?.task_id ?? null;
const connectionID = mode === "agent" ? sessionId : environmentId;
const environmentEnded = useEnvironmentEnded(environmentId);
const canConnect = computeCanConnect(mode, connectionID, sessionId, environmentEnded);
const wsBaseUrl = useWsBaseUrl();
type PassthroughConnectionState = {
isTerminalReady: boolean;
isConnected: boolean;
onTerminalReady: () => void;
onConnected: () => void;
onDisconnected: () => void;
};
function usePassthroughConnection({
connectionID,
autoFocus,
onXtermReady,
xtermRef,
attachAddonRef,
wsRef,
}: PassthroughConnectionOptions): PassthroughConnectionState {
const [isTerminalReady, setIsTerminalReady] = useState(false);
const onTerminalReady = useCallback(() => {
setIsTerminalReady(true);
@@ -250,28 +265,79 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
// overlay resets on target switches without needing a separate setState effect.
const [connectedTargetId, setConnectedTargetId] = useState<string | null>(null);
const isConnected = connectionID != null && connectedTargetId === connectionID;
const paneState = computeTerminalPaneState(mode, environmentEnded, isConnected);
const passthroughEscapeGuard = useCallback(
(event: KeyboardEvent) =>
shouldGuardPassthroughEscape(
event,
xtermRef.current?.textarea,
isConnected &&
attachAddonRef.current !== null &&
wsRef.current?.readyState === WebSocket.OPEN,
),
[attachAddonRef, isConnected, wsRef, xtermRef],
);
useClarificationEscapeGuard(passthroughEscapeGuard);
const onConnected = useCallback(() => {
setConnectedTargetId(connectionID ?? null);
if (autoFocus) refs.xtermRef.current?.textarea?.focus({ preventScroll: true });
}, [connectionID, autoFocus, refs.xtermRef]);
if (autoFocus) xtermRef.current?.textarea?.focus({ preventScroll: true });
}, [connectionID, autoFocus, xtermRef]);
const onDisconnected = useCallback(() => {
const disconnectedTargetId = connectionID ?? null;
setConnectedTargetId((current) => (current === disconnectedTargetId ? null : current));
}, [connectionID]);
return { isTerminalReady, isConnected, onTerminalReady, onConnected, onDisconnected };
}
type PassthroughEffectsOptions = {
props: PassthroughTerminalProps;
refs: ReturnType<typeof useTerminalRefs>;
terminalId: string | undefined;
environmentId: string | null | undefined;
taskId: string | null;
sessionId: string | null | undefined;
canConnect: boolean;
wsBaseUrl: string;
resolvedTheme: ReturnType<typeof useTheme>["resolvedTheme"];
connection: PassthroughConnectionState;
};
function usePassthroughEffects({
props,
refs,
terminalId,
environmentId,
taskId,
sessionId,
canConnect,
wsBaseUrl,
resolvedTheme,
connection,
}: PassthroughEffectsOptions) {
const {
mode,
label,
pendingCommand,
onCommandSent,
disableWebgl,
manualInputRouting,
onWsReady,
enableTouchScroll,
} = props;
const { terminalRef, xtermRef, fitAddonRef, wsRef, attachAddonRef } = refs;
const { isTerminalReady, isConnected, onTerminalReady, onConnected, onDisconnected } = connection;
const linkHandler = useTerminalLinkHandler();
const terminalFontFamily = useAppStore((s) => s.userSettings.terminalFontFamily);
const terminalFontSize = useAppStore((s) => s.userSettings.terminalFontSize);
const sendResize = useSendResize(wsRef);
const fitAndResize = useFitAndResize({
xtermRef: refs.xtermRef,
fitAddonRef: refs.fitAddonRef,
terminalRef: refs.terminalRef,
xtermRef,
fitAddonRef,
terminalRef,
lastDimensionsRef: refs.lastDimensionsRef,
sendResize,
});
const sendInput = useSendInput(wsRef);
const toggleBottomTerminal = useAppStore((s) => s.toggleBottomTerminal);
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
@@ -286,9 +352,9 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
containerRef,
);
useTerminalInit({
terminalRef: refs.terminalRef,
xtermRef: refs.xtermRef,
fitAddonRef: refs.fitAddonRef,
terminalRef,
xtermRef,
fitAddonRef,
isInitializedRef: refs.isInitializedRef,
lastDimensionsRef: refs.lastDimensionsRef,
resizeTimeoutRef: refs.resizeTimeoutRef,
@@ -312,14 +378,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
resolvedTheme,
});
useTerminalBusyTracking(terminalId, xtermRef, mode === "shell", isTerminalReady);
useTouchScroll({
terminalRef: refs.terminalRef,
xtermRef: refs.xtermRef,
enabled: !!enableTouchScroll,
isTerminalReady,
});
useTouchScroll({ terminalRef, xtermRef, enabled: !!enableTouchScroll, isTerminalReady });
useWebSocketConnection({
taskId,
sessionId,
@@ -340,8 +399,47 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
manualInputRouting,
onWsReady,
});
usePendingCommand(pendingCommand, isConnected, wsRef, onCommandSent);
return { containerRef, search, isConnected };
}
export function PassthroughTerminal(props: PassthroughTerminalProps) {
const { mode, autoFocus, onXtermReady } = props;
const { resolvedTheme } = useTheme();
const terminalId = mode === "shell" ? props.terminalId : undefined;
const environmentId = mode === "shell" ? props.environmentId : undefined;
const refs = useTerminalRefs();
const { terminalRef, xtermRef, wsRef, attachAddonRef } = refs;
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const environmentSessionId = useEnvironmentSessionId();
const sessionId = mode === "agent" ? (props.sessionId ?? storeSessionId) : environmentSessionId;
const { session } = useSession(sessionId);
const taskId = session?.task_id ?? null;
const connectionID = mode === "agent" ? sessionId : environmentId;
const environmentEnded = useEnvironmentEnded(environmentId);
const canConnect = computeCanConnect(mode, connectionID, sessionId, environmentEnded);
const wsBaseUrl = useWsBaseUrl();
const connection = usePassthroughConnection({
connectionID,
autoFocus,
onXtermReady,
xtermRef,
attachAddonRef,
wsRef,
});
const paneState = computeTerminalPaneState(mode, environmentEnded, connection.isConnected);
const { containerRef, search } = usePassthroughEffects({
props,
refs,
terminalId,
environmentId,
taskId,
sessionId,
canConnect,
wsBaseUrl,
resolvedTheme,
connection,
});
return (
<div
@@ -4,12 +4,56 @@ import {
computeCanConnect,
computeTerminalPaneState,
isEnvironmentEnded,
shouldGuardPassthroughEscape,
} from "./passthrough-terminal";
import { reconnectDelayMs, startReconnectLoop } from "./ws-reconnect";
import type { Terminal } from "@xterm/xterm";
const WS_BASE_URL = "ws://localhost:38429";
function keyboardEvent(key: string, target: EventTarget, modifiers: KeyboardEventInit = {}) {
const event = new KeyboardEvent("keydown", { key, ...modifiers });
Object.defineProperty(event, "target", { value: target });
return event;
}
describe("shouldGuardPassthroughEscape", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("@covers AC-UI-QUICK-TERMINAL-001.11 claims unmodified Escape from xterm", () => {
const textarea = document.createElement("textarea");
document.body.append(textarea);
textarea.focus();
expect(shouldGuardPassthroughEscape(keyboardEvent("Escape", textarea), textarea)).toBe(true);
});
it("does not claim modified Escape or Escape from another focused control", () => {
const textarea = document.createElement("textarea");
const button = document.createElement("button");
document.body.append(textarea, button);
expect(
shouldGuardPassthroughEscape(keyboardEvent("Escape", textarea, { ctrlKey: true }), textarea),
).toBe(false);
button.focus();
expect(shouldGuardPassthroughEscape(keyboardEvent("Escape", button), textarea)).toBe(false);
});
it("lets the dialog handle Escape after the passthrough connection ends", () => {
const textarea = document.createElement("textarea");
document.body.append(textarea);
textarea.focus();
expect(shouldGuardPassthroughEscape(keyboardEvent("Escape", textarea), textarea, false)).toBe(
false,
);
});
});
describe("computeCanConnect", () => {
it("lets the backend wait for an agent passthrough session that is not cached as ready", () => {
expect(computeCanConnect("agent", "session-1", "session-1")).toBe(true);
+9 -4
View File
@@ -53,10 +53,10 @@ function decodeBinaryFrame(payload: Buffer | Uint8Array): string | null {
*
* - JSON `{action: "shell.input", payload: {session_id, data}}` over the
* kandev gateway WS — the per-session default shell.
* - Raw binary frames over a PassthroughTerminal's dedicated WS — used by
* mobile multi-terminal where the on-screen terminal owns its own
* AttachAddon connection. The session ID is unknown for these frames
* (the WS is env+terminalId scoped) so an empty string is reported.
* - Raw text or binary frames over a PassthroughTerminal's dedicated WS.
* AttachAddon sends ordinary xterm input as text; manual mobile routing
* sends binary. The session ID is unknown for these frames, so an empty
* string is reported.
*
* Tests assert on the `data` field, which works the same either way.
*
@@ -67,9 +67,14 @@ function decodeBinaryFrame(payload: Buffer | Uint8Array): string | null {
export function attachShellInputCapture(page: Page): { frames: ShellInputFrame[] } {
const frames: ShellInputFrame[] = [];
page.on("websocket", (ws) => {
const isTerminalSocket = new URL(ws.url()).pathname.startsWith("/terminal/");
ws.on("framesent", (event) => {
const payload = event.payload;
if (typeof payload === "string") {
if (isTerminalSocket) {
if (payload) frames.push({ sessionId: "", data: payload });
return;
}
if (!payload.includes('"shell.input"')) return;
try {
const msg = JSON.parse(payload) as ParsedFrame;
+62 -1
View File
@@ -2,7 +2,8 @@ import { type Locator, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { attachAvailableCommandsCapture } from "../../helpers/ws-capture";
import { attachAvailableCommandsCapture, attachShellInputCapture } from "../../helpers/ws-capture";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import {
openQuickChatSetup,
openQuickChatWithAgent,
@@ -132,6 +133,66 @@ test.describe("Quick Chat", () => {
await expect(origin).not.toHaveAttribute("data-quick-chat-silent-focus");
});
test("@covers AC-UI-QUICK-TERMINAL-001.10 AC-UI-QUICK-TERMINAL-001.11 passthrough Escape stays in the TUI and the shortcut closes Quick Chat", async ({
testPage,
apiClient,
seedData,
}) => {
const capture = attachShellInputCapture(testPage);
const { agents } = await apiClient.listAgents();
const profileOwner = agents.find((agent) =>
agent.profiles?.some((profile) => profile.id === seedData.agentProfileId),
);
if (!profileOwner) throw new Error("Seed agent profile owner was not found");
let profileId: string | null = null;
try {
const profile = await apiClient.createAgentProfile(profileOwner.id, "Quick Chat Escape TUI", {
model: "mock-fast",
cli_passthrough: true,
});
profileId = profile.id;
await apiClient.updateWorkspace(seedData.workspaceId, {
default_agent_profile_id: profile.id,
});
const dialog = await openQuickChatSetup(testPage);
await dialog.getByTestId("quick-chat-start").click();
const terminal = dialog.getByTestId("passthrough-terminal");
await expect(terminal).toBeVisible({ timeout: 15_000 });
await expect(terminal.getByTestId("passthrough-loading")).not.toBeVisible({
timeout: 30_000,
});
await terminal.click();
await expect(terminal.locator(".xterm-helper-textarea")).toBeFocused();
capture.frames.length = 0;
await testPage.keyboard.press("Escape");
await expect(dialog).toBeVisible();
await expect
.poll(() => capture.frames.some((frame) => frame.data === "\x1b"), {
message: "Expected xterm to forward Escape to the passthrough terminal WebSocket",
})
.toBe(true);
const modifier = process.platform === "darwin" ? "Meta" : "Control";
const quickChatShortcut = `${modifier}+Shift+${SHORTCUTS.QUICK_CHAT.key}`;
await testPage.keyboard.press(quickChatShortcut);
await expect(dialog).not.toBeVisible();
} finally {
try {
await apiClient.updateWorkspace(seedData.workspaceId, {
default_agent_profile_id: seedData.agentProfileId,
});
} finally {
if (profileId) await apiClient.cleanupTestProfiles([profileId]);
}
}
});
test("clarification shortcuts work after clicking the message surface", async ({ testPage }) => {
const dialog = await openQuickChatWithAgent(testPage);
await sendQuickChatMessage(dialog, testPage, "/e2e:clarification-multi");
+38 -1
View File
@@ -1,10 +1,19 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const requestQuickChatClose = vi.hoisted(() => vi.fn());
vi.mock("@/components/quick-chat/quick-chat-focus", () => ({
captureQuickChatLauncherFocus: vi.fn(),
requestQuickChatClose,
}));
const openQuickChat = vi.fn();
const closeQuickChat = vi.fn();
const WORKSPACE_ID = "workspace-1";
const ACTIVE_CONFIG_ID = "config-active";
let activeSessionId: string | null = null;
let isOpen = false;
let sessions: Array<{
sessionId: string;
workspaceId: string;
@@ -13,7 +22,7 @@ let sessions: Array<{
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ openQuickChat, quickChat: { sessions, activeSessionId } }),
selector({ openQuickChat, closeQuickChat, quickChat: { isOpen, sessions, activeSessionId } }),
}));
import { useQuickChatLauncher } from "./use-quick-chat-launcher";
@@ -21,10 +30,38 @@ import { useQuickChatLauncher } from "./use-quick-chat-launcher";
beforeEach(() => {
sessions = [];
activeSessionId = null;
isOpen = false;
openQuickChat.mockReset();
closeQuickChat.mockReset();
requestQuickChatClose.mockReset();
requestQuickChatClose.mockReturnValue(false);
});
describe("useQuickChatLauncher typed sessions", () => {
it("@covers AC-UI-QUICK-TERMINAL-001.10 closes an open dialog when toggle behavior is enabled", () => {
isOpen = true;
requestQuickChatClose.mockReturnValue(true);
const { result } = renderHook(() =>
useQuickChatLauncher(WORKSPACE_ID, "chat", { toggleWhenOpen: true }),
);
act(() => result.current());
expect(requestQuickChatClose).toHaveBeenCalledTimes(1);
expect(closeQuickChat).not.toHaveBeenCalled();
expect(openQuickChat).not.toHaveBeenCalled();
});
it("keeps ordinary launchers open-only when the dialog is already open", () => {
isOpen = true;
const { result } = renderHook(() => useQuickChatLauncher(WORKSPACE_ID));
act(() => result.current());
expect(closeQuickChat).not.toHaveBeenCalled();
expect(openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID, undefined, "chat");
});
it("opens an ordinary session from the generic quick chat launcher", () => {
sessions = [
{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat" },
+23 -2
View File
@@ -1,10 +1,14 @@
import { useCallback } from "react";
import { useAppStore } from "@/components/state-provider";
import { captureQuickChatLauncherFocus } from "@/components/quick-chat/quick-chat-focus";
import {
captureQuickChatLauncherFocus,
requestQuickChatClose,
} from "@/components/quick-chat/quick-chat-focus";
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
type QuickChatLauncherOptions = {
silentFocusReturn?: boolean;
toggleWhenOpen?: boolean;
};
/**
@@ -17,12 +21,19 @@ export function useQuickChatLauncher(
options: QuickChatLauncherOptions = {},
) {
const silentFocusReturn = options.silentFocusReturn ?? true;
const toggleWhenOpen = options.toggleWhenOpen ?? false;
const openQuickChat = useAppStore((state) => state.openQuickChat);
const closeQuickChat = useAppStore((state) => state.closeQuickChat);
const isQuickChatOpen = useAppStore((state) => state.quickChat.isOpen);
const quickChatSessions = useAppStore((state) => state.quickChat.sessions);
const activeSessionId = useAppStore((state) => state.quickChat.activeSessionId);
const handleOpenQuickChat = useCallback(() => {
if (!workspaceId) return;
if (toggleWhenOpen && isQuickChatOpen) {
if (!requestQuickChatClose()) closeQuickChat();
return;
}
captureQuickChatLauncherFocus({ silent: silentFocusReturn });
// If there's an existing session, open it. Otherwise just open the modal with agent picker
@@ -43,7 +54,17 @@ export function useQuickChatLauncher(
// Open modal without a session - will show agent picker
openQuickChat("", workspaceId, undefined, kind);
}
}, [workspaceId, kind, quickChatSessions, activeSessionId, openQuickChat, silentFocusReturn]);
}, [
workspaceId,
toggleWhenOpen,
isQuickChatOpen,
closeQuickChat,
silentFocusReturn,
quickChatSessions,
kind,
activeSessionId,
openQuickChat,
]);
return handleOpenQuickChat;
}
@@ -0,0 +1,107 @@
---
created: 2026-08-28
status: done
requirements:
- REQ-UI-QUICK-TERMINAL-001
system_design:
- ../../specs/ui/system-design/quick-terminal.md
legacy_specs: []
---
# Implementation Plan: Quick Chat Passthrough Escape
## Overview
Make the existing configurable Quick Chat action a safe dialog toggle, then let a focused agent
passthrough terminal keep unmodified Escape for its TUI. Both changes ship as one vertical repair
because terminal Escape cannot be freed until the shared dialog has a discoverable keyboard close
path.
## Scope
### In scope
- Toggle the shared Quick Chat dialog from the existing configurable `QUICK_CHAT` action.
- Keep other Quick Chat launchers open/select-only.
- Prevent Radix dismissal only while a connected xterm terminal with an installed AttachAddon and
open WebSocket owns unmodified Escape.
- Preserve xterm's normal Escape-byte delivery to the agent terminal WebSocket.
- Preserve nested Quick Chat escape guards and the existing mobile close action.
### Out of scope
- A new "Escape closes Quick Chat" preference.
- Changes to agent cancellation APIs or provider-specific key mappings.
- Changes to bottom terminal, mobile terminal, or Settings terminal composition.
- Changes to pointer dismissal or Quick Chat geometry.
## Technical approach
### Configurable dialog toggle
Extend `useQuickChatLauncher` with an explicit toggle option. When enabled and the live shared
dialog state is open, request the registered modal close handler and fall back to `closeQuickChat`
when no handler exists; otherwise retain the current kind-scoped session/setup selection. Enable
this option only for the ordinary Quick Chat action registered by
`GlobalCommands`, which already resolves the user's `QUICK_CHAT` override.
Register that binding in capture phase with propagation stopped after a match so it wins before
xterm and remains an application command rather than terminal input.
### Focus-scoped terminal Escape
Add a stable Escape predicate to `PassthroughTerminal` and register it through
`useClarificationEscapeGuard`. Arm it only while the terminal is connected, its AttachAddon is
installed, and its WebSocket is open; then match only unmodified Escape directed at xterm's helper textarea.
The dialog prevents its own dismissal while allowing the same keyboard event to continue to xterm
and `AttachAddon`.
### Responsive behavior
The desktop outcome is a keyboard-toggleable shared dialog whose focused passthrough terminal owns
Escape. The mobile entry remains the existing Home/task-switcher Quick Chat action, and the nearest
shipped exemplar is `mobile-quick-chat-entry.spec.ts`: the full-height dialog keeps its visible
close control, internal scroll ownership, dynamic viewport sizing, and safe-area behavior. No
mobile composition or touch target changes are required.
## Tests
- `apps/web/hooks/use-quick-chat-launcher.test.ts`: cover opt-in close behavior, retained default
open behavior, and session preservation through the existing close action.
- `apps/web/components/quick-chat/quick-chat-focus.test.ts` and
`apps/web/components/quick-chat/use-quick-chat-modal.test.ts`: cover the registered close
lifecycle and cancellation of a deferred agent start.
- `apps/web/components/global-commands.test.tsx`: prove the configurable ordinary Quick Chat action
opts into toggle behavior while Configuration Chat does not, and that the keyboard listener has
capture-phase precedence over xterm.
- `apps/web/components/task/use-passthrough-terminal.test.ts`: cover the focus- and modifier-scoped
Escape predicate.
- `apps/web/components/quick-chat/quick-chat-modal.test.tsx`: retain the existing nested-widget
escape-guard regressions.
## E2E tests
- `apps/web/e2e/tests/chat/quick-chat.spec.ts`: for
`AC-UI-QUICK-TERMINAL-001.10` and `AC-UI-QUICK-TERMINAL-001.11`, create a passthrough Quick Chat,
focus xterm, observe an Escape frame on its terminal WebSocket, assert the dialog remains open,
then use the configured Quick Chat shortcut to close it.
- `apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts`: existing
`AC-UI-QUICK-TERMINAL-001.12` coverage proves the phone entry and explicit touch close path.
## Work orders
- [x] [Task 01: Repair Quick Chat passthrough cancellation](task-01-passthrough-cancellation.md)
## Verification results
- Focused Vitest suites: 6 files and 71 tests passed, including the registered close lifecycle and
deferred-start cancellation regression.
- Desktop Chromium E2E: passthrough Escape delivery and Quick Chat shortcut dismissal passed.
- Mobile Chrome E2E: Home-header launch and explicit touch dismissal passed.
- Targeted ESLint, specification lint, and `git diff --check` passed.
## Risks
- A predicate broader than xterm's helper textarea could swallow Escape from terminal search or
other nested controls.
- Calling `stopPropagation()` would starve xterm; the dialog guard must use `preventDefault()` only.
- Applying toggle semantics to every launcher would change existing kind-selection behavior.
@@ -0,0 +1,109 @@
---
id: "01-passthrough-cancellation"
title: "Repair Quick Chat passthrough cancellation"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-QUICK-TERMINAL-001
acceptance_criteria:
- AC-UI-QUICK-TERMINAL-001.10
- AC-UI-QUICK-TERMINAL-001.11
- AC-UI-QUICK-TERMINAL-001.12
system_design:
- ../../specs/ui/system-design/quick-terminal.md
---
# Task 01: Repair Quick Chat Passthrough Cancellation
## Summary
Give the existing configurable Quick Chat action opt-in toggle semantics and protect a focused
agent passthrough xterm from Radix's Escape dismissal. Prove that Escape reaches the terminal while
the dialog stays open, and that the configured action remains a reliable close path.
## In scope
- Add and test the toggle option on `useQuickChatLauncher`.
- Enable the option for the global ordinary Quick Chat action only.
- Register a focus-scoped, unmodified Escape predicate from `PassthroughTerminal` only while its
AttachAddon and terminal WebSocket are connected.
- Add desktop browser evidence for terminal delivery, dialog retention, and shortcut dismissal.
- Re-run the existing mobile touch-close scenario.
## Out of scope
- A new user setting or locale copy.
- Synthetic Escape writes or changes to agent cancellation APIs.
- Terminal behavior outside a guard-enabled dismissible surface.
- UI layout, pointer dismissal, or mobile composition changes.
## Acceptance
- The configured Quick Chat action closes an open shared dialog and keeps its sessions intact; all
non-toggle launchers retain their current selection behavior.
- Unmodified Escape from a connected xterm helper textarea is forwarded on the agent terminal
WebSocket and does not close Quick Chat; disconnected terminals and other focus targets retain
existing escape behavior.
- Existing nested-widget and mobile touch-dismissal paths remain green.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run hooks/use-quick-chat-launcher.test.ts components/global-commands.test.tsx components/task/use-passthrough-terminal.test.ts components/quick-chat/quick-chat-modal.test.tsx
cd apps/web && pnpm e2e:run tests/chat/quick-chat.spec.ts -- --grep "passthrough Escape"
cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-entry.spec.ts -- --grep "opens from the home header and closes with the touch control"
```
## Files likely touched
- `apps/web/hooks/use-quick-chat-launcher.ts`
- `apps/web/hooks/use-quick-chat-launcher.test.ts`
- `apps/web/components/global-commands.tsx`
- `apps/web/components/global-commands.test.tsx`
- `apps/web/components/task/passthrough-terminal.tsx`
- `apps/web/components/task/use-passthrough-terminal.test.ts`
- `apps/web/e2e/helpers/ws-capture.ts`
- `apps/web/e2e/tests/chat/quick-chat.spec.ts`
## Dependencies
None.
## Risks
- Radix handles Escape in document capture before xterm; the guard must match the terminal's exact
focus and modifier scope without stopping propagation.
- The E2E WebSocket observer must attach before the terminal socket opens and filter out the main
JSON gateway plus unrelated terminal frames.
## Parallelism
`sequential`
## Inputs
- `REQ-UI-QUICK-TERMINAL-001`, especially acceptance criteria `.10` through `.12`.
- `docs/specs/ui/system-design/quick-terminal.md`, section "Launcher toggle and terminal Escape
routing".
- Existing Quick Chat escape-guard tests and terminal WebSocket capture patterns.
## Results
- Added opt-in toggle semantics to the shared Quick Chat launcher and enabled them only for the
configurable global Quick Chat command. The launcher requests the mounted modal close lifecycle,
falling back to the store action only when no handler is registered. The capture-phase command
closes an open dialog before xterm can consume the configured chord.
- Registered a focused, unmodified-Escape guard from `PassthroughTerminal` only while its
AttachAddon is installed on an open WebSocket. Radix keeps the dialog open while xterm forwards
the same key as `\x1b` through `AttachAddon`.
- Extended the E2E WebSocket observer to capture AttachAddon's raw text frames in addition to the
existing JSON gateway and binary terminal frames.
- RED evidence: the focused unit tests initially failed on the missing toggle, shortcut options,
and terminal predicate; the desktop E2E initially failed because Escape closed the dialog.
- GREEN evidence:
- `cd apps && pnpm --filter @kandev/web test -- --run hooks/use-quick-chat-launcher.test.ts components/global-commands.test.tsx components/task/use-passthrough-terminal.test.ts components/quick-chat/quick-chat-modal.test.tsx` (4 files, 41 tests passed)
- `cd apps/web && pnpm e2e:run tests/chat/quick-chat.spec.ts -- --grep "passthrough Escape"` (1 Chromium test passed)
- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-entry.spec.ts -- --grep "opens from the home header and closes with the touch control"` (1 mobile-chrome test passed)
- Targeted ESLint, `python3 scripts/lint-spec-files.py --all`, and `git diff --check` passed.
+5 -1
View File
@@ -2,7 +2,7 @@
status: active
system: ui
created: 2026-08-03
updated: 2026-08-27
updated: 2026-08-28
owners:
- kandev
---
@@ -29,11 +29,15 @@ Quick Chat and Quick Terminal are both short-lived utilities reached from the sa
- **AC-UI-QUICK-TERMINAL-001.7:** Renameable conversation tabs expose **Rename** from a context menu on fine-pointer devices and from a visible tab action on coarse-pointer devices. The backing-task rename persistence remains unchanged. Terminal labels stay fixed.
- **AC-UI-QUICK-TERMINAL-001.8:** Multiple terminal tabs can run concurrently. Input, output, resize, exit, and error state belong to the selected terminal and must not affect sibling terminals.
- **AC-UI-QUICK-TERMINAL-001.9:** When Escape closes the shared dialog, the system shall return focus to the launcher without a visible focus indicator. When focus later returns through ordinary keyboard navigation, the launcher shall show its normal focus indicator.
- **AC-UI-QUICK-TERMINAL-001.10:** The user-configurable Quick Chat keyboard shortcut shall toggle the shared dialog. Activating the configured shortcut while the dialog is closed opens ordinary Quick Chat; activating it while the dialog is open closes the dialog without removing its chat or terminal sessions.
- **AC-UI-QUICK-TERMINAL-001.11:** When a connected agent passthrough terminal owns keyboard focus inside the shared dialog, with its xterm AttachAddon installed on an open terminal WebSocket, unmodified Escape shall reach the agent TUI and the dialog shall remain open. Escape from non-terminal focus targets, or while that terminal connection is unavailable, retains the existing dialog and nested-widget behavior.
- **AC-UI-QUICK-TERMINAL-001.12:** Phone and coarse-pointer users shall retain the existing visible Quick Chat close action and touch dismissal paths without depending on a hardware keyboard shortcut.
## Out of scope
- Changing pointer dismissal, tooltip behavior, or focus indicators on unrelated controls.
- Changing the Quick Chat layout or its mobile touch controls.
- Adding a separate preference for whether Escape closes Quick Chat. Focus ownership determines the behavior.
### REQ-UI-QUICK-TERMINAL-002: Quick Chat Tab Organization
+32 -2
View File
@@ -5,7 +5,7 @@ requirements:
- REQ-UI-QUICK-TERMINAL-001
- REQ-UI-QUICK-TERMINAL-002
created: 2026-08-03
updated: 2026-08-27
updated: 2026-08-28
owners:
- kandev
---
@@ -20,7 +20,7 @@ records and Quick Terminal descriptors remain owned by their existing backend se
| Requirement | Design section |
| --- | --- |
| `REQ-UI-QUICK-TERMINAL-001` | [Migrated source detail](#migrated-source-detail), [Launcher focus return](#launcher-focus-return) |
| `REQ-UI-QUICK-TERMINAL-001` | [Migrated source detail](#migrated-source-detail), [Launcher focus return](#launcher-focus-return), [Launcher toggle and terminal Escape routing](#launcher-toggle-and-terminal-escape-routing) |
| `REQ-UI-QUICK-TERMINAL-002` | [Tab order and editing](#tab-order-and-editing) |
## Tab order and editing
@@ -226,6 +226,36 @@ does not add the marker or move focus.
This contract applies to Quick Chat and Quick Terminal launchers that use the shared provider. It
does not change pointer dismissal, Configuration Chat focus ownership, or unrelated controls.
## Launcher toggle and terminal Escape routing
The configurable `QUICK_CHAT` binding uses a toggle-capable ordinary-chat launcher. The launcher
reads the live `quickChat.isOpen` state: it requests the mounted modal's close lifecycle, falling
back to `closeQuickChat` when no modal handler is registered, and otherwise follows the existing
workspace- and kind-scoped `openQuickChat` selection flow. Only the global keyboard/command action
enables toggle behavior for ordinary Quick Chat. The Settings Configuration Chat floating launcher
is an explicit pointer/touch exception: it also closes its controlled panel on reactivation. Other
pointer and touch launchers continue to open or select their matching content, and the existing
shortcut override remains the single settings and persistence contract. The global binding listens
in capture phase and stops a matched toggle chord before focus-trapped controls such as xterm can
consume it as terminal input.
`PassthroughTerminal` registers a memoized predicate with the existing
`ClarificationEscapeGuardProvider`. The predicate is armed only while the terminal is connected,
its `AttachAddon` is installed, and its dedicated WebSocket is open; then it claims only an
unmodified `Escape` whose event target, or current focus fallback, is xterm's helper textarea.
Radix consults the predicate from its document capture listener. A match calls `preventDefault()` to
stop dialog dismissal but does not stop propagation, so xterm's normal keyboard handler emits the
Escape byte and `AttachAddon` forwards it on the dedicated terminal WebSocket.
The guard hook remains a no-op outside a provider. Bottom-panel and mobile terminal embeddings
therefore keep their current behavior, and focus on a Quick Chat tab, composer, setup control, or
terminal search control does not arm the terminal guard. Existing clarification, suggestion, and
reverse-search guards keep their two-stage behavior.
This change adds no setting, persisted state, or mobile composition. The phone dialog retains its
full-height surface and visible close action; the existing mobile Quick Chat entry test remains the
touch-dismissal evidence.
## Data model
Decision: [ADR-2026-08-05-server-owned-quick-terminal-descriptors](../../../decisions/2026-08-05-server-owned-quick-terminal-descriptors.md)