diff --git a/apps/desktop/native/appsnap/OptionChordMonitor.swift b/apps/desktop/native/appsnap/ModifierChordMonitor.swift similarity index 51% rename from apps/desktop/native/appsnap/OptionChordMonitor.swift rename to apps/desktop/native/appsnap/ModifierChordMonitor.swift index 4a7073a0..43a708f8 100644 --- a/apps/desktop/native/appsnap/OptionChordMonitor.swift +++ b/apps/desktop/native/appsnap/ModifierChordMonitor.swift @@ -1,25 +1,89 @@ import CoreGraphics import Foundation -private let leftOptionKey = CGKeyCode(0x3A) -private let rightOptionKey = CGKeyCode(0x3D) -private let leftOptionDeviceFlag = CGEventFlags(rawValue: 0x20) -private let rightOptionDeviceFlag = CGEventFlags(rawValue: 0x40) +/// Which pair of physical modifier keys must be held together to trigger AppSnap. +/// Must stay in sync with `DesktopAppSnapChord` in packages/contracts/src/ipc.ts. +enum ModifierChord: String { + case option + case shift + case control + case command -private func optionChordEventCallback( + static let `default`: ModifierChord = .option + + var label: String { + switch self { + case .option: return "Option" + case .shift: return "Shift" + case .control: return "Control" + case .command: return "Command" + } + } + + var leftKeyCode: CGKeyCode { + switch self { + case .option: return CGKeyCode(0x3A) + case .shift: return CGKeyCode(0x38) + case .control: return CGKeyCode(0x3B) + case .command: return CGKeyCode(0x37) + } + } + + var rightKeyCode: CGKeyCode { + switch self { + case .option: return CGKeyCode(0x3D) + case .shift: return CGKeyCode(0x3C) + case .control: return CGKeyCode(0x3E) + case .command: return CGKeyCode(0x36) + } + } + + // Device-dependent modifier bits (IOLLEvent.h NX_DEVICE*KEYMASK). Unlike the higher-level + // `CGEventFlags.mask*` constants below, these distinguish which physical side (left/right) + // of the same logical modifier is down, which is what the chord detection needs. + var leftDeviceFlag: CGEventFlags { + switch self { + case .option: return CGEventFlags(rawValue: 0x20) + case .shift: return CGEventFlags(rawValue: 0x02) + case .control: return CGEventFlags(rawValue: 0x01) + case .command: return CGEventFlags(rawValue: 0x08) + } + } + + var rightDeviceFlag: CGEventFlags { + switch self { + case .option: return CGEventFlags(rawValue: 0x40) + case .shift: return CGEventFlags(rawValue: 0x04) + case .control: return CGEventFlags(rawValue: 0x2000) + case .command: return CGEventFlags(rawValue: 0x10) + } + } + + var overallMask: CGEventFlags { + switch self { + case .option: return .maskAlternate + case .shift: return .maskShift + case .control: return .maskControl + case .command: return .maskCommand + } + } +} + +private func modifierChordEventCallback( proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, userInfo: UnsafeMutableRawPointer? ) -> Unmanaged? { if let userInfo { - let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() monitor.receive(type: type, event: event) } return Unmanaged.passUnretained(event) } -final class OptionChordMonitor { +final class ModifierChordMonitor { + private let chord: ModifierChord private let emitter: NDJSONEmitter private let onChord: () -> Void private var tap: CFMachPort? @@ -30,7 +94,8 @@ final class OptionChordMonitor { private var latched = false private var lastErrorCode: String? - init(emitter: NDJSONEmitter, onChord: @escaping () -> Void) { + init(chord: ModifierChord, emitter: NDJSONEmitter, onChord: @escaping () -> Void) { + self.chord = chord self.emitter = emitter self.onChord = onChord } @@ -50,7 +115,7 @@ final class OptionChordMonitor { emitter.emitError( AppSnapFailure( code: "event_tap_disabled", - message: "macOS disabled the passive Option-key listener; TeaCode re-enabled it." + message: "macOS disabled the passive \(chord.label)-key listener; TeaCode re-enabled it." ), capturedAt: appSnapTimestamp() ) @@ -58,11 +123,11 @@ final class OptionChordMonitor { } guard type == .flagsChanged else { return } let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) - guard keyCode == leftOptionKey || keyCode == rightOptionKey else { return } + guard keyCode == chord.leftKeyCode || keyCode == chord.rightKeyCode else { return } - leftDown = event.flags.contains(leftOptionDeviceFlag) - rightDown = event.flags.contains(rightOptionDeviceFlag) - if !event.flags.contains(.maskAlternate) { + leftDown = event.flags.contains(chord.leftDeviceFlag) + rightDown = event.flags.contains(chord.rightDeviceFlag) + if !event.flags.contains(chord.overallMask) { reset() return } @@ -91,7 +156,7 @@ final class OptionChordMonitor { guard CGPreflightListenEventAccess() else { reportOnce(AppSnapFailure( code: "input-monitoring-required", - message: "Input Monitoring permission is required for the two-Option-key shortcut." + message: "Input Monitoring permission is required for the two-\(chord.label)-key shortcut." )) return false } @@ -101,13 +166,13 @@ final class OptionChordMonitor { place: .headInsertEventTap, options: .listenOnly, eventsOfInterest: flagsChangedMask, - callback: optionChordEventCallback, + callback: modifierChordEventCallback, userInfo: Unmanaged.passUnretained(self).toOpaque() ), let newSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, newTap, 0) else { reportOnce(AppSnapFailure( code: "event_tap_unavailable", - message: "macOS could not create the passive Option-key listener." + message: "macOS could not create the passive \(chord.label)-key listener." )) return false } diff --git a/apps/desktop/native/appsnap/WindowCapture.swift b/apps/desktop/native/appsnap/WindowCapture.swift index f14f025d..d5354b3b 100644 --- a/apps/desktop/native/appsnap/WindowCapture.swift +++ b/apps/desktop/native/appsnap/WindowCapture.swift @@ -40,18 +40,6 @@ private func number(_ dictionary: [String: Any], _ key: CFString) -> NSNumber? { } func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result { - guard let app = NSWorkspace.shared.frontmostApplication, !app.isTerminated else { - return .failure(AppSnapFailure( - code: "no_frontmost_application", - message: "There is no frontmost application to capture." - )) - } - guard app.bundleIdentifier != bundleIdentifier else { - return .failure(AppSnapFailure( - code: "excluded_frontmost_application", - message: "TeaCode does not capture its own window." - )) - } guard let windows = CGWindowListCopyWindowInfo( [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID @@ -64,8 +52,9 @@ func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result 0, (number(candidate, kCGWindowIsOnscreen)?.boolValue ?? true), @@ -77,6 +66,15 @@ func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result= 2 else { continue } + if let selectedApplication, ownerPID != selectedApplication.processIdentifier { + break + } + guard let application = NSRunningApplication(processIdentifier: ownerPID), + !application.isTerminated, + application.bundleIdentifier != bundleIdentifier + else { continue } + selectedApplication = application + let title = (candidate[kCGWindowName as String] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) if let title, !title.isEmpty { selected = (rawWindowID, bounds, title) @@ -87,7 +85,13 @@ func selectFrontmostWindow(excluding bundleIdentifier: String) -> Result void { const channels = DESKTOP_IPC_CHANNELS.appSnap; ipcMain.removeHandler(channels.getState); ipcMain.removeHandler(channels.setEnabled); + ipcMain.removeHandler(channels.setChord); ipcMain.removeHandler(channels.requestPermissions); ipcMain.removeHandler(channels.listPendingCaptures); ipcMain.removeHandler(channels.acknowledgeCapture); @@ -18,6 +19,10 @@ export function registerAppSnapIpc(manager: DesktopAppSnapManager): () => void { if (typeof enabled !== "boolean") throw new Error("Invalid AppSnap enabled state."); return manager.setEnabled(enabled); }); + ipcMain.handle(channels.setChord, (_event, chord: unknown) => { + if (!isDesktopAppSnapChord(chord)) throw new Error("Invalid AppSnap chord."); + return manager.setChord(chord); + }); ipcMain.handle(channels.requestPermissions, () => manager.requestPermissions()); ipcMain.handle(channels.listPendingCaptures, () => manager.listPendingCaptures()); ipcMain.handle(channels.acknowledgeCapture, (_event, captureId: unknown) => { @@ -30,6 +35,7 @@ export function registerAppSnapIpc(manager: DesktopAppSnapManager): () => void { return () => { ipcMain.removeHandler(channels.getState); ipcMain.removeHandler(channels.setEnabled); + ipcMain.removeHandler(channels.setChord); ipcMain.removeHandler(channels.requestPermissions); ipcMain.removeHandler(channels.listPendingCaptures); ipcMain.removeHandler(channels.acknowledgeCapture); diff --git a/apps/desktop/src/appSnapManager.test.ts b/apps/desktop/src/appSnapManager.test.ts index 870709f3..6e2c2231 100644 --- a/apps/desktop/src/appSnapManager.test.ts +++ b/apps/desktop/src/appSnapManager.test.ts @@ -317,6 +317,84 @@ describe("AppSnap helper protocol and paths", () => { }); }); +describe("AppSnap chord configuration", () => { + it("defaults to the option chord and reports it as the shortcut", () => { + const manager = createManager("/tmp/teacode-appsnap-chord-default"); + expect(manager.getState().shortcut).toBe("option"); + }); + + it("restarts a running watcher with the new chord", async () => { + const directory = mkdtempSync(join(tmpdir(), "teacode-appsnap-chord-")); + const checkChild = createFakeChildProcess(); + const watchChild = createFakeChildProcess(); + const restartedWatchChild = createFakeChildProcess(); + const spawn = vi + .fn() + .mockReturnValueOnce(checkChild) + .mockReturnValueOnce(watchChild) + .mockReturnValueOnce(restartedWatchChild) as unknown as typeof ChildProcess.spawn; + const manager = new DesktopAppSnapManager({ + platform: "darwin", + helperPath: process.execPath, + captureDirectory: directory, + excludedBundleId: "dev.jow.TeaCode.dev", + spawn, + onState: vi.fn(), + onCaptured: vi.fn(), + onError: vi.fn(), + }); + try { + const enable = manager.setEnabled(true); + await flushPromises(); + checkChild.stdout.end( + `${JSON.stringify({ + type: "permissions", + inputMonitoring: "granted", + screenRecording: "granted", + })}\n`, + ); + checkChild.stderr.end(); + checkChild.emit("close", 0, null); + await enable; + expect(spawn).toHaveBeenNthCalledWith( + 2, + process.execPath, + expect.arrayContaining(["--chord", "option"]), + expect.anything(), + ); + + await manager.setChord("shift"); + expect(watchChild.kill).toHaveBeenCalledWith("SIGTERM"); + expect(manager.getState().shortcut).toBe("shift"); + expect(spawn).toHaveBeenNthCalledWith( + 3, + process.execPath, + expect.arrayContaining(["--chord", "shift"]), + expect.anything(), + ); + } finally { + manager.dispose(); + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("does not restart or respawn when the chord is unchanged", async () => { + const spawn = vi.fn() as unknown as typeof ChildProcess.spawn; + const manager = new DesktopAppSnapManager({ + platform: "darwin", + helperPath: process.execPath, + captureDirectory: "/tmp/teacode-appsnap-chord-noop", + excludedBundleId: "dev.jow.TeaCode.dev", + spawn, + onState: vi.fn(), + onCaptured: vi.fn(), + onError: vi.fn(), + }); + await manager.setChord("option"); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + describe("durable AppSnap pending captures", () => { it("restores, caps, and acknowledges private pending pairs", async () => { const directory = mkdtempSync(join(tmpdir(), "teacode-appsnap-pending-")); diff --git a/apps/desktop/src/appSnapManager.ts b/apps/desktop/src/appSnapManager.ts index 732aa496..7388f6ba 100644 --- a/apps/desktop/src/appSnapManager.ts +++ b/apps/desktop/src/appSnapManager.ts @@ -13,12 +13,25 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type DesktopAppSnapCapture, + type DesktopAppSnapChord, type DesktopAppSnapErrorEvent, type DesktopAppSnapPermission, type DesktopAppSnapPlatform, type DesktopAppSnapState, } from "@t3tools/contracts"; +const DEFAULT_APP_SNAP_CHORD: DesktopAppSnapChord = "option"; +const APP_SNAP_CHORDS: ReadonlySet = new Set([ + "option", + "shift", + "control", + "command", +]); + +export function isDesktopAppSnapChord(value: unknown): value is DesktopAppSnapChord { + return typeof value === "string" && APP_SNAP_CHORDS.has(value as DesktopAppSnapChord); +} + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const PENDING_VERSION = 1; const MAX_METADATA_BYTES = 512 * 1024; @@ -304,6 +317,7 @@ export class DesktopAppSnapManager { }; readonly #platform: DesktopAppSnapPlatform; #enabled = false; + #chord: DesktopAppSnapChord = DEFAULT_APP_SNAP_CHORD; #status: DesktopAppSnapState["status"]; #message: string | null; #inputPermission: DesktopAppSnapPermission = "unknown"; @@ -338,7 +352,7 @@ export class DesktopAppSnapManager { supported: this.#platform === "macos", enabled: this.#enabled, status: this.#status, - shortcut: this.#platform === "macos" ? "both-option-keys" : null, + shortcut: this.#platform === "macos" ? this.#chord : null, inputMonitoringPermission: this.#inputPermission, screenRecordingPermission: this.#screenPermission, message: this.#message, @@ -369,6 +383,18 @@ export class DesktopAppSnapManager { return this.getState(); } + async setChord(chord: DesktopAppSnapChord): Promise { + if (this.#platform !== "macos" || this.#disposed || this.#chord === chord) { + return this.getState(); + } + this.#chord = chord; + // The watcher only reads the chord at spawn time, so a live change has to restart it + // — the same reconcile path `setEnabled` uses, which no-ops if nothing is watching yet. + if (this.#watchProcess) this.#stopWatcher(); + await this.#reconcile(); + return this.getState(); + } + async listPendingCaptures(): Promise { await this.#ensurePendingLoaded(); return this.#pending.map(({ capture }) => ({ @@ -573,6 +599,8 @@ export class DesktopAppSnapManager { this.#options.captureDirectory, "--excluded-bundle-id", this.#options.excludedBundleId, + "--chord", + this.#chord, ], { stdio: ["ignore", "pipe", "pipe"] }, ); diff --git a/apps/desktop/src/ipcChannels.ts b/apps/desktop/src/ipcChannels.ts index 5b89e2f5..b608c023 100644 --- a/apps/desktop/src/ipcChannels.ts +++ b/apps/desktop/src/ipcChannels.ts @@ -29,6 +29,7 @@ export const DESKTOP_IPC_CHANNELS = { appSnap: { getState: "desktop:appsnap:get-state", setEnabled: "desktop:appsnap:set-enabled", + setChord: "desktop:appsnap:set-chord", requestPermissions: "desktop:appsnap:request-permissions", listPendingCaptures: "desktop:appsnap:list-pending-captures", acknowledgeCapture: "desktop:appsnap:acknowledge-capture", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4665dbe0..add81f9f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -195,6 +195,12 @@ const DESKTOP_MENU_MAX_ZOOM_FACTOR = 5; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; let mainWindow: BrowserWindow | null = null; +// Startup opens and destroys hidden storage-bridge windows (migrateLegacyRendererStorage) +// before the real window exists. Destroying them drops the window count to zero, which +// would fire window-all-closed and quit the app on non-macOS platforms before it ever +// showed a window. This latches once the real window has existed so that premature +// all-closed events are ignored, while closing the real window still quits normally. +let mainWindowEverCreated = false; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendAuthToken = ""; @@ -2610,6 +2616,7 @@ function createWindow(): BrowserWindow { backgroundThrottling: true, }, }); + mainWindowEverCreated = true; attachDesktopZoomFactorSync(window); window.webContents.on("context-menu", (event, params) => { @@ -2872,7 +2879,9 @@ if (hasSingleInstanceLock) { } app.on("window-all-closed", () => { - if (process.platform !== "darwin") { + // Ignore all-closed events fired by the transient storage-bridge windows that + // migrateLegacyRendererStorage opens and destroys before the real window exists. + if (process.platform !== "darwin" && mainWindowEverCreated) { app.quit(); } }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 521a02ed..0a2b70b9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -124,6 +124,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { appSnap: { getState: () => ipcRenderer.invoke(APP_SNAP_CHANNELS.getState), setEnabled: (enabled) => ipcRenderer.invoke(APP_SNAP_CHANNELS.setEnabled, enabled), + setChord: (chord) => ipcRenderer.invoke(APP_SNAP_CHANNELS.setChord, chord), requestPermissions: () => ipcRenderer.invoke(APP_SNAP_CHANNELS.requestPermissions), listPendingCaptures: () => ipcRenderer.invoke(APP_SNAP_CHANNELS.listPendingCaptures), acknowledgeCapture: (captureId) => diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 0f3164bb..c9bde5f8 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { AppSettingsSchema, + DEFAULT_APP_SNAP_CHORD, DEFAULT_CHAT_FONT_SIZE_PX, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_TASK_LIST_DISPLAY_MODE, @@ -678,6 +679,7 @@ describe("AppSettingsSchema", () => { confirmTerminalTabClose: true, enableAssistantStreaming: true, enableAppSnap: false, + appSnapChord: DEFAULT_APP_SNAP_CHORD, appSnapPlaySound: true, sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 4e732939..4ee18862 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -68,7 +68,11 @@ export const DEFAULT_SIDEBAR_POSITION: SidebarPosition = "left"; /** Optional decorative image behind the main chat canvas. */ export const TaskListDisplayMode = Schema.Literals(["sidebar", "composer"]); export type TaskListDisplayMode = typeof TaskListDisplayMode.Type; -export const DEFAULT_TASK_LIST_DISPLAY_MODE: TaskListDisplayMode = "sidebar"; +export const DEFAULT_TASK_LIST_DISPLAY_MODE: TaskListDisplayMode = "composer"; +/** Which pair of physical modifier keys must be held together to trigger AppSnap. */ +export const AppSnapChord = Schema.Literals(["option", "shift", "control", "command"]); +export type AppSnapChord = typeof AppSnapChord.Type; +export const DEFAULT_APP_SNAP_CHORD: AppSnapChord = "option"; export const ChatHeaderControlIdSchema = Schema.Literals(DEFAULT_CHAT_HEADER_CONTROL_ORDER); @@ -155,6 +159,7 @@ export const AppSettingsSchema = Schema.Struct({ enableTaskCompletionToasts: Schema.Boolean.pipe(withDefaults(() => true)), enableSystemTaskCompletionNotifications: Schema.Boolean.pipe(withDefaults(() => true)), enableAppSnap: Schema.Boolean.pipe(withDefaults(() => false)), + appSnapChord: AppSnapChord.pipe(withDefaults(() => DEFAULT_APP_SNAP_CHORD)), appSnapPlaySound: Schema.Boolean.pipe(withDefaults(() => true)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), diff --git a/apps/web/src/components/AppSnapCoordinator.tsx b/apps/web/src/components/AppSnapCoordinator.tsx index 97979bce..26410805 100644 --- a/apps/web/src/components/AppSnapCoordinator.tsx +++ b/apps/web/src/components/AppSnapCoordinator.tsx @@ -1,7 +1,7 @@ // FILE: AppSnapCoordinator.tsx // Purpose: Delivers desktop AppSnaps into TeaCode composers with durable acknowledgement. -import type { DesktopAppSnapCapture, ThreadId } from "@t3tools/contracts"; +import type { DesktopAppSnapCapture, DesktopAppSnapState, ThreadId } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useRef } from "react"; @@ -96,6 +96,7 @@ export function AppSnapCoordinator() { const focusedThreadRef = useRef(focusedThreadId); const lastInteractionRef = useRef(null); const lastAppSnapRef = useRef(null); + const lastAppSnapStatusRef = useRef(null); const captureQueueRef = useRef>(Promise.resolve()); const seenCaptureIdsRef = useRef(new Set()); const blobHydrationInFlightRef = useRef(new Set()); @@ -262,6 +263,17 @@ export function AppSnapCoordinator() { }); }, [settings.enableAppSnap]); + useEffect(() => { + const bridge = window.desktopBridge?.appSnap; + if (!bridge) return; + // The manager only picks up the configured chord at watcher-spawn time, and doesn't persist + // it itself, so it has to be re-pushed here (like `enableAppSnap` above) every time the app + // starts, not just when the Settings picker changes it. + void bridge.setChord(settings.appSnapChord).catch((error) => { + console.warn("[appsnap] Could not update the native chord", error); + }); + }, [settings.appSnapChord]); + const routeToThread = useCallback( async (threadId: ThreadId) => { if (focusedThreadRef.current !== threadId) { @@ -442,6 +454,35 @@ export function AppSnapCoordinator() { .catch((error) => console.warn("[appsnap] Capture queue failed", error)); }; + // The manager privately checks/requests OS permission only when the user flips the + // Settings toggle. If that permission later goes missing (a dialog dismissed, one of + // the two prompts missed, access revoked), the watcher never starts and the shortcut + // silently does nothing — this is the only place that tells the user outside Settings. + const handleAppSnapState = (state: DesktopAppSnapState) => { + const previousStatus = lastAppSnapStatusRef.current; + lastAppSnapStatusRef.current = state.status; + if (!enableAppSnapRef.current) return; + if (state.status !== "permission-required" && state.status !== "error") return; + if (previousStatus === state.status) return; + toastManager.add({ + type: "error", + title: "AppSnap needs attention", + description: state.message ?? "AppSnap can't listen for the shortcut right now.", + data: { allowCrossThreadVisibility: true }, + actionProps: { + children: "Open settings", + onClick: () => { + void navigate({ to: "/settings", search: { section: "appsnap" } }); + }, + }, + }); + }; + const unsubscribeState = bridge.onState(handleAppSnapState); + void bridge + .getState() + .then(handleAppSnapState) + .catch((error) => console.warn("[appsnap] Could not read the initial AppSnap state", error)); + const unsubscribeCapture = bridge.onCaptured((capture) => enqueueCapture(capture, true)); const unsubscribeError = bridge.onError((error) => { toastManager.add({ @@ -471,10 +512,11 @@ export function AppSnapCoordinator() { .catch((error) => console.warn("[appsnap] Could not restore pending captures", error)); return () => { disposed = true; + unsubscribeState(); unsubscribeCapture(); unsubscribeError(); }; - }, []); + }, [navigate]); return null; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9780392b..8539cdb1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2133,13 +2133,19 @@ export default function ChatView({ latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; - // Forced open only for states that need the composer in view: a running turn to + // Pops the composer open once for states that need it in view: a running turn to // watch or stop, and prompts that need answering. Connecting is not one of them // — a research Thread connects its session on open, which kept the composer // expanded on arrival even though the disclosure defaults to closed. + // This only opens it — collapsing must stay a manual action the user can take back + // at any time afterward, so it is never wired into `composerDisclosureOpen` itself + // (see the effect below). const composerDisclosureForcedOpen = phase === "running" || activePendingApproval !== null || pendingUserInputs.length > 0; - const composerDisclosureOpen = !composerCollapsed || composerDisclosureForcedOpen; + const composerDisclosureOpen = !composerCollapsed; + useEffect(() => { + if (composerDisclosureForcedOpen) setComposerCollapsed(false); + }, [composerDisclosureForcedOpen]); const serverAcknowledgedLocalDispatch = useMemo( () => hasServerAcknowledgedLocalDispatch({ @@ -4391,7 +4397,6 @@ export default function ChatView({ if (command === "composer.collapse.toggle") { event.preventDefault(); event.stopPropagation(); - if (composerDisclosureForcedOpen) return; setComposerCollapsed((collapsed) => { if (collapsed) window.requestAnimationFrame(() => scheduleComposerFocus()); return !collapsed; @@ -4446,7 +4451,6 @@ export default function ChatView({ }, [ activeProject, activeThreadId, - composerDisclosureForcedOpen, runProjectScript, keybindings, onToggleDiff, @@ -7883,29 +7887,29 @@ export default function ChatView({ className="flex shrink-0 items-center gap-1" > {/* Collapsing is a keyboard shortcut, which nothing advertises. - This is the affordance that makes it findable; it hides while - a turn or prompt needs the composer, matching the shortcut. */} - {composerDisclosureForcedOpen ? null : ( - setComposerCollapsed(true)} - > - - - )} + This is the affordance that makes it findable — always available, + even mid-turn or with a prompt pending; that state only pops the + composer open once (see the effect near composerDisclosureOpen), + it never locks it open. */} + setComposerCollapsed(true)} + > + + {activePendingProgress ? (