From 0563169e730ad4391d5c65c225a471bfc30f8d7b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 17 Sep 2026 02:16:16 -0400 Subject: [PATCH 1/2] fix: clear cancelled Live picker state --- .papercuts/troubleshooting.md | 1 + docs/plans/gemini-live-assistant-plan.md | 2 ++ renderer/components/assistant/assistant-live.tsx | 2 +- renderer/components/assistant/assistant-ui.test.tsx | 1 + renderer/components/assistant/use-assistant-live.test.tsx | 8 ++++++++ renderer/components/assistant/use-assistant-live.ts | 3 +++ 6 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 6da2a690..88113cab 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -528,3 +528,4 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed. - Pullfrog completed only after GitHub had already accepted the exact-head merge, and it found release-blocking screen-capture races. Keep publication cancellable until external review finishes, even when first-party CI and the merge gate are green. - Electron exposes setters but no getters for session permission handlers. Scope the Live handler to the lifetime of exact display bindings, explicitly admit only display capture and microphone for that bound document, then restore the default handlers when the last binding disappears. - Electron's `media` permission covers camera as well as microphone; check `mediaType` on permission checks and exact `mediaTypes: ["audio"]` on permission requests instead of treating the permission name as audio-only. +- A green external-review check can race a final inline comment by seconds. Re-read unresolved review threads after the check completes and make the merge command conditional on an empty result rather than chaining inspection and merge unconditionally. diff --git a/docs/plans/gemini-live-assistant-plan.md b/docs/plans/gemini-live-assistant-plan.md index f227fe39..7d3f6128 100644 --- a/docs/plans/gemini-live-assistant-plan.md +++ b/docs/plans/gemini-live-assistant-plan.md @@ -5,6 +5,8 @@ Date: 2026-09-15 Continuous orb follow-up (2026-09-17): active listening/thinking/speaking/acting now share stable Listening + Weaving canvas layers with a slow complementary blend and connection/rest crossfades. Removed visible dock status text (retained screen-reader status). First active click reveals Stop; the second stops, Escape restores the orb, and closing disables duplicate clicks. Sharing/error surfaces remain visible. Added mapping/click-policy regressions; all 230 Live tests, type-check, lint, and standalone browser interaction/motion checks pass. Downloads duplex-review.html uses the actual orb component; the installed notarized app has not been rebuilt for this change. Release hardening follow-up (2026-09-17): Pullfrog's post-merge review blocked 0.41.4 publication until screen authority is generation-fenced. Exact binding tokens now prevent stale releases from revoking replacements, late picker streams stop after teardown, rejected frame sends revoke capture, and temporary Electron permission handlers admit only audio-only media for the exact document before restoring defaults when the final binding closes. Source Change/Remove is locked across pending provider startup and start failure releases the exact current binding. Adversarial coverage was added for each path. + +Final picker-state follow-up (2026-09-17): Live start is also blocked while the native picker is pending, and every authority-cancellation path synchronously clears the picker busy state before a later picker can begin. This prevents a superseded picker from leaving setup permanently disabled. Combined artifact verification (2026-09-17): Apple accepted notarization submission `5b7f74d8-4740-47b9-82d8-66704cb75ec7`; the Downloads Combined Notarized app has a stapled ticket and passes the repository notarized-package/Gatekeeper check. Opened with the preserved isolated Test Profile and verified its renderer and Start Aiden Live control. Full Live suites, type-check, lint, audio/settings tests, and Electron selector-layout regression passed. Native-picker and real-provider acceptance remain separate and pending. Combined build (2026-09-17): integrated the screen-sharing work from the 2f30 worktree into this audio-fix checkout without changing the source worktree. Preserved extended-thinking model selection, dedicated session threads, async thread finalization, direct actions, diagnostic markers, empty-envelope handling, device routing, and dropdown layout. Screen capture remains separately gated and off in the ordinary test launcher pending the exact-build native-picker acceptance receipt. Notarization and attended acceptance are separate gates; this is not a public release. Audio selector layout follow-up: scoped single-line trigger styling and actual Radix value-span ellipsis fix long device labels pushing chevrons outside the control. Real Electron geometry regression passes at 1280, 600, and 390px; the full label remains available through the dropdown and hover title. diff --git a/renderer/components/assistant/assistant-live.tsx b/renderer/components/assistant/assistant-live.tsx index 00847396..2a754a15 100644 --- a/renderer/components/assistant/assistant-live.tsx +++ b/renderer/components/assistant/assistant-live.tsx @@ -126,7 +126,7 @@ export function AssistantLiveSetupDialog({ title="Set up Aiden Live" description="Give Aiden only the access it needs. Nothing is captured until you start a Live session." confirmLabel={live.busy ? "Starting…" : "Start Live"} - confirmDisabled={!live.setupComplete || live.busy || Boolean(live.startBlockedReason)} + confirmDisabled={!live.setupComplete || live.busy || live.screenBusy || Boolean(live.startBlockedReason)} cancelLabel={live.busy ? "Stop" : "Not now"} allowCancelWhileBusy actionClassName={ASSISTANT_LIVE_FOCUS_CLASS} diff --git a/renderer/components/assistant/assistant-ui.test.tsx b/renderer/components/assistant/assistant-ui.test.tsx index 8ef87629..5ea64cb0 100644 --- a/renderer/components/assistant/assistant-ui.test.tsx +++ b/renderer/components/assistant/assistant-ui.test.tsx @@ -280,6 +280,7 @@ test("screen sharing is opt-in, source-labelled, and visibly active in the HUD", assert.match(live, /live\.releaseScreen/u); assert.match(live, /busy=\{live\.screenBusy \|\| live\.busy\}/u); assert.match(live, /disabled=\{live\.screenBusy \|\| live\.busy\}/u); + assert.match(live, /confirmDisabled=\{!live\.setupComplete \|\| live\.busy \|\| live\.screenBusy/u); assert.match(live, /Sharing \{live\.screenSourceLabel \?\? "screen"\}/u); assert.match(live, /role="alert"[\s\S]*live\.screenError/u); }); diff --git a/renderer/components/assistant/use-assistant-live.test.tsx b/renderer/components/assistant/use-assistant-live.test.tsx index 7ce47c3b..b27060ff 100644 --- a/renderer/components/assistant/use-assistant-live.test.tsx +++ b/renderer/components/assistant/use-assistant-live.test.tsx @@ -1652,14 +1652,21 @@ test("releasing setup fences and stops a display stream returned by a late picke const choosing = fixture.controller().chooseScreenSource(); await settle(); assert.deepEqual(state.binds, [1]); + assert.equal(fixture.controller().screenBusy, true); + + await fixture.controller().start(); + assert.equal(state.startCalls.length, 0, "Live cannot start while its picker is pending"); fixture.controller().releaseScreen(); + await settle(); + assert.equal(fixture.controller().screenBusy, false); picker.resolve({ getTracks: () => [state.displayTrack] }); await choosing; await settle(); assert.equal(state.displayTrack.stops, 1, "the late stream is stopped immediately"); assert.deepEqual(state.releases, ["binding-1"]); + assert.equal(fixture.controller().screenBusy, false); assert.equal(fixture.controller().screenSourceLabel, null); await fixture.unmount(); }); @@ -1669,6 +1676,7 @@ test("a rejected screen frame stops capture and releases its exact binding", asy state.admitFrames = false; const fixture = await mountHook(dependencies); await fixture.controller().chooseScreenSource(); + await settle(); await fixture.controller().start(); await settle(); await settle(); diff --git a/renderer/components/assistant/use-assistant-live.ts b/renderer/components/assistant/use-assistant-live.ts index bb50aa81..8b6a7362 100644 --- a/renderer/components/assistant/use-assistant-live.ts +++ b/renderer/components/assistant/use-assistant-live.ts @@ -586,6 +586,7 @@ export function useAssistantLiveWithDependencies( screenPickerGeneration.current += 1; const bindingId = screenBindingRef.current; screenBindingRef.current = null; + if (mounted.current) setScreenBusy(false); if (bindingId) { void dependencies.api.releaseDisplay?.(bindingId).catch(() => undefined); } @@ -1140,6 +1141,7 @@ export function useAssistantLiveWithDependencies( const start = React.useCallback(async () => { if ( busy || + screenBusy || dependencies.ordinaryBusyReason || !snapshot.available || !["granted", "not-determined"].includes(microphonePermission) || @@ -1223,6 +1225,7 @@ export function useAssistantLiveWithDependencies( } }, [ busy, + screenBusy, computerUseEnabled, dependencies, microphone, From f72b012de000ed6ce19e533dc3b258a340012942 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 17 Sep 2026 02:28:50 -0400 Subject: [PATCH 2/2] fix: rearm Live picker after API refresh --- .papercuts/troubleshooting.md | 1 + .../assistant/use-assistant-live.test.tsx | 39 +++++++++++++++++++ .../assistant/use-assistant-live.ts | 4 ++ 3 files changed, 44 insertions(+) diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 88113cab..edd3a9c0 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -529,3 +529,4 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed. - Electron exposes setters but no getters for session permission handlers. Scope the Live handler to the lifetime of exact display bindings, explicitly admit only display capture and microphone for that bound document, then restore the default handlers when the last binding disappears. - Electron's `media` permission covers camera as well as microphone; check `mediaType` on permission checks and exact `mediaTypes: ["audio"]` on permission requests instead of treating the permission name as audio-only. - A green external-review check can race a final inline comment by seconds. Re-read unresolved review threads after the check completes and make the merge command conditional on an empty result rather than chaining inspection and merge unconditionally. +- React effect cleanup marks the hook unmounted before revoking picker authority; reconfiguration needs the replacement effect setup to reset UI ownership, while final unmount must not schedule state recovery. diff --git a/renderer/components/assistant/use-assistant-live.test.tsx b/renderer/components/assistant/use-assistant-live.test.tsx index b27060ff..32d1d1c3 100644 --- a/renderer/components/assistant/use-assistant-live.test.tsx +++ b/renderer/components/assistant/use-assistant-live.test.tsx @@ -147,6 +147,7 @@ interface HookFixture { player: RecordingPlayer; startIntents(): Array<{ microphone: boolean; screen?: boolean; computerUseAuthorization: string | null }>; refreshAvailability(): Promise; + refreshApi(): Promise; unmount(): Promise; } @@ -314,6 +315,15 @@ async function mountHook(overrides: Partial = {}): Pr await settle(); flushSync(() => undefined); }, + refreshApi: async () => { + dependencies = { + ...dependencies, + api: { ...dependencies.api }, + }; + flushSync(() => root.render()); + await settle(); + flushSync(() => undefined); + }, unmount: async () => { flushSync(() => root.unmount()); await settle(); @@ -1671,6 +1681,35 @@ test("releasing setup fences and stops a display stream returned by a late picke await fixture.unmount(); }); +test("an API refresh cancels a pending picker and re-arms screen choice", async () => { + const { dependencies, state } = screenDependencies(); + const firstPicker = deferred(); + let picks = 0; + const fixture = await mountHook({ + ...dependencies, + getDisplayMedia: async () => { + picks += 1; + if (picks === 1) return firstPicker.promise; + return { getTracks: () => [state.displayTrack] }; + }, + }); + const staleChoice = fixture.controller().chooseScreenSource(); + await settle(); + assert.equal(fixture.controller().screenBusy, true); + + await fixture.refreshApi(); + assert.equal(fixture.controller().screenBusy, false); + await fixture.controller().chooseScreenSource(); + await settle(); + assert.equal(picks, 2, "the replacement effect accepts a new picker"); + + firstPicker.resolve({ getTracks: () => [new FakeDisplayTrack()] }); + await staleChoice; + await settle(); + assert.equal(fixture.controller().screenSourceLabel, "Aiden window"); + await fixture.unmount(); +}); + test("a rejected screen frame stops capture and releases its exact binding", async () => { const { dependencies, state } = screenDependencies(); state.admitFrames = false; diff --git a/renderer/components/assistant/use-assistant-live.ts b/renderer/components/assistant/use-assistant-live.ts index 8b6a7362..39f272a5 100644 --- a/renderer/components/assistant/use-assistant-live.ts +++ b/renderer/components/assistant/use-assistant-live.ts @@ -702,6 +702,10 @@ export function useAssistantLiveWithDependencies( React.useEffect(() => { mounted.current = true; + // A dependency refresh may have cancelled an in-flight picker during the + // previous effect's cleanup. The replacement effect now owns the hook and + // must make the picker re-armable; final unmount has no replacement setup. + setScreenBusy(false); if (!dependencies.geminiLive) return () => { mounted.current = false;