Skip to content

fix(x): one hover companion — delete the summoned ask bar, no flash, no dead summons - #878

Merged
arkml merged 4 commits into
mainfrom
fix/hover-companion-reveal
Aug 20, 2026
Merged

fix(x): one hover companion — delete the summoned ask bar, no flash, no dead summons#878
arkml merged 4 commits into
mainfrom
fix/hover-companion-reveal

Conversation

@arkml

@arkml arkml commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

The ⌥⇧Space hover mode ("quick access") had three real bugs, found by driving the dev app over CDP and snapshotting the companion window every ~25 ms:

  1. Old bar flashes before the Skipper. The first summon created the companion window and show()ed it while the page was still loading — index.html painted a theme-colored slab, then the renderer's default summoned layout (the old text bar), then the Skipper. Re-summons flashed the summoned layout for a frame too (the hidden window keeps that layout; show() fired before the re-render).
  2. Hover mode comes up once, then every later chord is dead. startHoverCall held its in-flight guard across the sticky screen share, and getDisplayMedia hangs forever when Screen Recording is denied / its prompt is unanswered (the state after every rebuild) — so the guard stayed latched and every later ⌥⇧Space was a silent no-op.
  3. Latent crash: QuickAskBar called useMemo after conditional early returns, so a pill ⇄ card switch (camera toggle mid-call) would throw React's hook-count error and blank the window.

Changes

  • Reveal protocol (quick-ask.tsquick-ask-bar.tsx, shared/ipc.ts): quick-ask:mode carries a seq; the renderer acks quickAsk:modeApplied two frames after committing that role; main orders the window in at opacity 0 and sets opacity 1 / focuses only on the ack. Fold pushes the layout first and shrinks the window on the ack (no squeezed-card frame). Renderer paints nothing until its role is known; index.html gives #quick-ask / #screen-pointer a transparent background from the first paint. Timeouts (600 ms / 6 s while loading) keep a wedged renderer from blocking.
  • Hover flow hardening (App.tsx, useVideoMode.ts): guard released right after startCall; sticky/share screen share is fire-and-forget; startScreenShare is time-boxed (10 s) and fails cleanly with the existing permission dialog; a session that fails to start falls back to the text card (never a silent no-op).
  • One flow, every entry point: chord, tray item, card tuck-handle, toast "Try it", composer call button and the "Share screen" preset all end in startHoverCall(). Tray/toast no longer arm hold-to-talk. A summon with the app window closed or still loading recreates it hidden (initQuickAsk({ ensureAppWindow })) and re-relays on a quickAsk:appReady handshake; closing the app window mid-call unpins the companion. Call-state cache survives fullscreen ⇄ popout (camera calls come back as the pill, no card→pill morph) and is cleared by an explicit idle push at call end.
  • Hooks hoisted above the early returns in QuickAskBar; [companion] mode-transition breadcrumbs in main; settings/toast copy and VIDEO_MODE.md updated.

Verification

  • npm run lint, npm run typecheck clean.
  • CDP-driven runtime checks on the dev build: first summon reveals the Skipper ~75 ms after page load with no summoned-layout frame (previously ~50–600 ms of the old bar); re-summon reveals ~50 ms after the mode push via the ack (no timeout); fold/unfold ordering correct; end → re-summon works repeatedly (previously dead after the first sticky-share hang); summon with the app window closed recreates it hidden and pins the Skipper in ~1.5 s; text-card fallback and the card's tuck handle → Skipper transition verified.
  • Not runtime-tested here: the camera/pill surface (no camera in the test env) and Windows/Linux (setOpacity is a no-op on Linux → old behavior there).

🤖 Generated with Claude Code

arkml and others added 2 commits August 20, 2026 02:09
…ne flow for every entry point

The ⌥⇧Space hover mode (quick access) had three real bugs:

1. The first summon created the companion window and show()ed it while
   the page was still loading: index.html painted a theme slab, then the
   renderer's default summoned layout (the old text bar), then the
   Skipper. Re-summons flashed the summoned layout for a frame too (the
   hidden window keeps it, and show() fired before the re-render).
2. startHoverCall held its in-flight guard across the sticky screen
   share; getDisplayMedia hangs forever when Screen Recording is denied,
   so after the first summon every later chord was a silent no-op.
3. QuickAskBar called useMemo after conditional early returns — a pill ⇄
   card switch (camera toggle) would throw React's hook-count error and
   blank the window.

Fixes:
- Reveal protocol: quick-ask:mode carries a seq, the renderer acks
  quickAsk:modeApplied two frames after committing that role, and main
  orders the window in at opacity 0 and only makes it opaque / focuses on
  the ack. Fold pushes first and shrinks on the ack. The renderer paints
  nothing until its role is known; index.html gives #quick-ask and
  #screen-pointer a transparent background from the very first paint.
- startScreenShare is time-boxed (10s) and fails cleanly; the hover guard
  is released right after startCall and the share is fire-and-forget; a
  session that fails to start falls back to the text card.
- ONE hover flow: chord, tray, tuck handle, toast "Try it", composer call
  button and the "Share screen" preset all end in startHoverCall(). A
  summon with no (or a still-loading) app window recreates it hidden and
  re-relays on a quickAsk:appReady handshake; closing the app window
  mid-call unpins the companion. Call-state cache survives fullscreen ⇄
  popout (camera calls return as the pill) and is cleared by an explicit
  idle push at call end. Hooks hoisted above the early returns.
- [companion] mode-transition breadcrumbs in main; VIDEO_MODE.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ead of answering with the text card

The remaining "old quick access comes up instead of hover mode" case:
voiceAvailable/ttsAvailable start false and are filled by an async probe
(voice:getConfig + oauth:getState) at mount. A chord in the first seconds
after reopening the app read that not-yet-resolved false as "no voice
configured" and answered with quickAsk:show — the old text card, with no
Skipper ever arriving.

Reproduced deterministically by delaying the probe: main logged
`hidden → summoned (text card (fresh window))`. With this change the same
run logs `hidden → pinned (call surface (summoned))`.

- App: mirror the probe result into refs (written inside the probe, so a
  waiter doesn't depend on a re-render), keep the in-flight promise, and
  have startHoverCall await it (capped at 4s, starting one if nothing has
  probed yet) before deciding. The in-flight guard now covers that wait,
  and a call started meanwhile just re-asserts its floating surface. A
  genuinely unconfigured voice still falls back to the text card.
- Bar: the tuck handle is optimistic until its own probe answers — the app
  window owns the authoritative check and its own fallback, so an early
  click relays instead of dying as a dimmed no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arkml

arkml commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up (38ff97d): found one more path to the old text card, the one that matches "reopen the app, then press the shortcut".

voiceAvailable/ttsAvailable start false and are filled by an async probe (voice:getConfig + oauth:getState) at mount, so a chord in the first seconds after an app start read that not-yet-resolved false as "no voice configured" and answered with quickAsk:show — the old bar, with no Skipper ever arriving.

Reproduced deterministically by delaying the probe: main logged hidden → summoned (text card (fresh window)); with the fix the same run logs hidden → pinned (call surface (summoned)). A genuinely unconfigured voice still falls back to the text card (verified separately), and the bar's tuck handle is now optimistic until its own probe answers so an early click relays instead of dying dimmed.

… surface

The old Spotlight-style quick-ask bar is gone, not just avoided. It was
only ever a fallback surface, and every "hover mode is glitchy" report was
that bar appearing where the Skipper belonged. With one role left there is
no second layout to flash, race, or get stuck in.

Removed:
- main: the `summoned` mode, its geometry (positionSummoned, FRAME_*,
  BOTTOM_MARGIN), showSummonedCard/showQuickAsk/hideQuickAsk, the
  blur-to-dismiss handler, and the trigger enum. `toggleQuickAsk()` now has
  exactly one outcome: fold/unfold the Skipper's text when it's up, else
  relay a summon. An unanswered relay is re-sent once by a watchdog and
  then logged — never answered with a different surface.
- IPC: quickAsk:show / quickAsk:hide / quickAsk:stop + quick-ask:stop /
  quickAsk:state + quick-ask:state / quickAsk:setOptions +
  quick-ask:set-options / quickAsk:optionsState + quick-ask:options-state /
  quick-ask:summoned. CompanionMode is now 'hidden' | 'pinned'.
- renderer: the bar layout, its answer panel, local dictation and the
  ⌥⇧Space hold-to-talk chord, the voice-out and share-without-a-call
  toggles, dismiss/stage-click-to-dismiss, and the tuck handle's
  start-a-call role. The window paints NOTHING unless it is pinned.
- App: quickAskActiveRef / quickAskOptionsRef / quickAskStartedAtRef /
  speakTurnRef and the answer-mirror effect. Speech now follows the
  question's modality alone.

A summon that can't become a session (voice unconfigured, or the engine
failed to start) is explained in the APP window — brought to the front
with a toast that opens Settings — instead of silently becoming a
different floating thing.

Verified on the dev build: earliest-possible summon after an app start
lands as the Skipper (no other layout at any frame), fold/unfold/end/
re-summon cycle clean, hidden window renders 0 bytes, and the no-voice
path shows the toast with the companion staying hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arkml arkml changed the title fix(x): hover companion — no flash of the old bar, no dead summons, one flow for every entry point fix(x): one hover companion — delete the summoned ask bar, no flash, no dead summons Aug 20, 2026
@arkml

arkml commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

5eeb56c — the old bar is deleted, not just avoided.

Per Arjun: it wasn't used anywhere, so the summoned role is gone entirely rather than kept as a fallback. Net −636 lines.

Removed: main's summoned mode + geometry + showQuickAsk/hideQuickAsk/showSummonedCard + blur-to-dismiss; the channels quickAsk:show, quickAsk:hide, quickAsk:stop/quick-ask:stop, quickAsk:state/quick-ask:state, quickAsk:setOptions/quick-ask:set-options, quickAsk:optionsState/quick-ask:options-state, quick-ask:summoned (CompanionMode is now 'hidden' | 'pinned'); the renderer's bar layout, answer panel, local dictation + hold-to-talk chord, voice-out/share-without-a-call toggles; and App's quickAskActiveRef/quickAskOptionsRef/speakTurnRef plumbing.

toggleQuickAsk() now has exactly one outcome: fold/unfold the Skipper if it's up, else relay a summon (re-sent once by a watchdog, then logged). A summon that can't become a session is explained in the app window with a toast that opens Settings — never by showing a different floating surface. The companion window renders 0 bytes unless it is pinned.

Verified on the dev build: earliest-possible summon after an app start lands as the Skipper with no other layout at any frame; fold → 250px mascot, unfold → card, end → hidden (0 bytes), re-summon → Skipper; no-voice path shows the toast with the companion staying hidden.

Comment- and copy-only follow-up to 5eeb56c: several comments still
described the summoned bar's status line, voice toggle, destination chip
and geometry, and two user-facing strings still called the feature "Quick
Ask". Nothing behavioural.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arkml
arkml merged commit ff78107 into main Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant