Skip to content

feat(macos): menu-bar Tray + extract main-window.ts + flip accessory mode (LUM-1965, PR 1)#32630

Merged
ashleeradka merged 9 commits into
mainfrom
claude/lum-1965-tray-icon
May 30, 2026
Merged

feat(macos): menu-bar Tray + extract main-window.ts + flip accessory mode (LUM-1965, PR 1)#32630
ashleeradka merged 9 commits into
mainfrom
claude/lum-1965-tray-icon

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 29, 2026

Summary

PR 1 of LUM-1965. Two things land together because the audit surfaced an architectural fix that closes the Codex / Devin findings by construction:

  1. Tray icon + click handlers (the original scope).
  2. apps/macos/src/main/main-window.ts extraction — the main window's lifecycle moves from index.ts into its own module, following the same installX + named-exports pattern as dock.ts / about.ts / settings.ts. Every callsite that wants to manipulate the main window composes from a single ensureVisible() primitive.
  3. ALLOW_ACCESSORY_MODE flips to true in dock.ts per the inline contract from feat(macos): Dock unread badge + visibility state machine (LUM-1966) #32461 ("flip to true in the same change that lands the tray").

Pulse status indicator + main-window-sizing change ship in PR 2 / PR 3.

Architectural rewrite: main-window.ts

The original sketch of this PR added a 4th variant of "make main window visible" to index.ts on top of the 3 already there (focusMainWindow, toggleMainWindowVisibility, the activate handler). Every future window-manipulating ticket (thread pop-outs LUM-1870, deep links LUM-1872, command palette LUM-1867, sleep/wake LUM-1974) would have added its own variant.

The main window was the one piece of UI that didn't follow the module-scope-state + installX + named-exports pattern that dock.ts, about.ts, settings.ts already use. Extracting now is small; later it's much bigger.

Public API:

Function Behavior
ensureVisible() The primitive. Recreate if destroyed, restore from minimize, show, focus. Every other surface composes from this.
hide() Hide if visible; no-op if destroyed.
toggleVisibility() Hide if visible and focused; otherwise ensureVisible(). The tray's left click + "Show / Hide Main Window" menu item compose from this.
isVisibleAndFocused() For callers that need to branch on "is the user looking at the main window right now?".
current() Escape hatch returning the underlying `BrowserWindow
installMainWindow() Bootstrap. Called once from whenReady.

createWindow, the will-navigate same-origin guard, the dev/prod URL strategy, and trackWindowState wiring all move into main-window.ts. index.ts shrinks by ~85 lines.

Tray (the original scope)

Same as the prior PR draft, with the handlers wired through the new primitives:

  • Programmatic template icon (16x16 antialiased outlined ring built via nativeImage.createFromBitmap — no binary assets in this PR; switches to createFromPath against shipped artwork when the assistant-avatar PR lands).
  • Left clicktoggleVisibility().
  • Right click → context menu via popUpContextMenu (no tray.setContextMenu() — with it, left and right click both open the same menu).
  • setIgnoreDoubleClickEvents(true) so fast single clicks fire as two click events instead of being coalesced into a swallowed double-click on macOS.
  • Menu: New Conversation, Current Conversation, Show / Hide Main Window, About Vellum, Quit.

Reviewer findings closed in this PR

Codex P2 — "Focus the main window before tray commands"

When the main window has been hidden from the tray, this menu item only sends newConversation to the existing hidden BrowserWindow; the renderer updates state but nothing makes the window visible, so choosing New Conversation from the tray appears to do nothing.

Closed by construction: TrayHandlers gains ensureMainWindow, and the New / Current Conversation menu items call it before dispatchToFocused(...). Test added asserting both items surface the window before dispatching.

Devin — "ALLOW_ACCESSORY_MODE not flipped despite tray landing"

The ALLOW_ACCESSORY_MODE flag at apps/macos/src/main/dock.ts:52 is explicitly documented to be "Flip[ped] to true in the same change that lands the tray." This PR adds the tray entry point but does not flip the flag.

Closed: flag flips to true in this PR. The previous comment is replaced with one that explains the post-tray behavior (signed-out + all-windows-closed → menu-bar-only, the tray is the always-available re-entry point).

Behavior changes called out

  1. app.on("second-instance") previously called focusMainWindow() which was a no-op when main had been destroyed. Now ensureVisible() recreates. Net fix — a second app launch should always surface a window.
  2. The activate handler previously only acted when mainWindow was null/destroyed. Now ensureVisible() ALSO brings an existing window to front when the user clicks the Dock icon while another app has focus. Matches Swift's activate behavior.
  3. ALLOW_ACCESSORY_MODE = true: signing out + closing all windows transitions the app to accessory mode (no Dock icon, menu-bar only) instead of staying in regular mode. The intended behavior from LUM-1966's design.

Tests

  • apps/macos/src/main/main-window.test.ts (new, 17 cases) — ensureVisible recreates / restores / focuses; hide is a no-op when destroyed; toggleVisibility composes correctly; isVisibleAndFocused branches on both flags; installMainWindow idempotent; current() returns null → live → null across lifecycle.
  • apps/macos/src/main/tray.test.ts (updated) — handlers grow ensureMainWindow; new test asserts conversation menu items call it before dispatching.
  • All 7 test files green at HEAD.

Out of scope (follow-up PRs)

  • PR 2 (pulse status indicator) — 5-state state machine with image-swap animation. Lands when the connection-status signal source is settled.
  • PR 3 (main-window sizing)1280 × 8001200 × 900 with minWidth: 800, minHeight: 600, matching Swift. Trivial but a separate concern.
  • Avatar artwork (waits on the assistant-identity bridge).
  • Connection-status feed (waits on BFF auth + daemon supervisor).

Test plan

  • bun --cwd apps/macos run typecheck — green.
  • bun --cwd apps/macos run test:ci — 7 test files, all green.
  • bun --cwd apps/macos run build — main + preload bundles build cleanly.
  • Manual: bun run dev shows a circle icon in the macOS menu bar; left-click toggles the main window; right-click opens the menu; New Conversation from the tray surfaces the window AND dispatches.
  • Manual: sign out + close main window → Dock icon disappears, tray icon stays. Click tray → main window recreates.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe

@linear
Copy link
Copy Markdown

linear Bot commented May 29, 2026

LUM-1965

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f953cf9c97

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/macos/src/main/tray.ts Outdated
{
label: "New Conversation",
accelerator: resolveAccelerator("newConversation"),
click: () => dispatchToFocused({ kind: "newConversation" }),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Focus the main window before tray commands

When the main window has been hidden from the tray (or after the Dock moves to accessory mode), this menu item only sends newConversation to the existing hidden BrowserWindow; the renderer updates state but nothing makes the window visible, so choosing New Conversation from the tray appears to do nothing. The Swift status-item action calls showMainWindow() before creating/selecting a conversation, so the Electron tray action should likewise create/focus/show the main window before dispatching (the Current Conversation item has the same issue).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

⚠️ 1 issue in files not directly in the diff

⚠️ ALLOW_ACCESSORY_MODE not flipped to true despite tray landing in this PR (apps/macos/src/main/dock.ts:52)

The ALLOW_ACCESSORY_MODE flag at apps/macos/src/main/dock.ts:52 is explicitly documented to be "Flip[ped] to true in the same change that lands the tray." This PR adds the tray entry point (installTray in tray.ts, wired in index.ts:354-357), but does not flip the flag. As a result, the Dock icon will never transition to accessory mode (hidden) when all windows are closed and the user is signed out — it will always remain regular. The tray now provides an alternative entry point back to the app, so the safety gate that kept the flag false is no longer needed.

View 2 additional findings in Devin Review.

Open in Devin Review

@ashleeradka ashleeradka changed the title feat(macos): menu-bar (Tray) status item — icon + click + context menu (LUM-1965, PR 1) feat(macos): menu-bar Tray + extract main-window.ts + flip accessory mode (LUM-1965, PR 1) May 29, 2026
@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 29, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 1112e1c5f0 — 6/6 CI green, 7 files +820/-115.

Headline. PR 1 of LUM-1965, but the substance isn't the tray — it's that the audit on the first sketch surfaced an architectural fix that closes both reviewer findings by construction. The first commit (f953cf9c97) added the tray as a 4th "make the main window visible" variant on top of the 3 already living in index.ts. The second commit (1112e1c5f0) extracted main-window.ts as the lifecycle owner, collapsed all 4 variants into a single ensureVisible() primitive, and the Codex P2 ("focus before dispatch") + Devin ("ALLOW_ACCESSORY_MODE not flipped") findings both close because the new shape makes them impossible to forget. This is the right way for a bot-driven review cycle to land — the second commit isn't a patch on the first, it's the structural change the findings were pointing at.

Full read

The extraction lands the pattern, not just the file

apps/macos/src/main/ now has four lifecycle-owner modules following the same installX + named-export + module-scope-state shape (about.ts, dock.ts, settings.ts, and now main-window.ts). Every future window-manipulating ticket — thread pop-outs LUM-1870, deep links LUM-1872, command palette LUM-1867, sleep/wake LUM-1974 — composes from ensureVisible() instead of growing index.ts by another 20-line branch. The cost of writing the abstraction now is small; the cost of writing it later, after 4 more callers have each invented their own "what does visible mean?" recipe, is much larger. This is the same first-principles framing #32626 and #32622 just applied to a different module.

main-window.ts API is clean

  • ensureVisible() is the only call site that creates a window. installMainWindow is just a once-only wrapper that calls it from whenReady. activate, second-instance, and the tray all compose from the same primitive — no opportunity for the "destroyed window + dispatch" race the P2 finding named.
  • toggleVisibility() correctly gates on isVisibleAndFocused rather than just isVisible. So clicking the tray icon while the main window is showing-but-behind-Slack focuses it instead of hiding it, which is what users expect from menu-bar apps (Things, Linear, etc.).
  • current() is the BrowserWindow escape hatch reserved for dispatchToFocused + before-quit cleanup. Not used in this PR — pure API surface for the next 1-2 PRs. Same "ship the abstraction first" pattern as #32604.
  • installed boolean guards bootstrap idempotency; mainWindow === win check in the closed handler avoids the stale-reference race if a previous-window's close event arrives after a new window has been created.

Reviewer findings closed by construction

  • Codex P2 (focus before dispatch). New ensureMainWindow handler on TrayHandlers; conversation menu items call it before dispatchToFocused. Test apps/macos/src/main/tray.test.ts:163-167 asserts the count of ensureMainWindow calls increments by 2 across New + Current Conversation clicks. Tray menu-bar item now matches Swift's NSStatusItem behavior (showMainWindow → dispatch).
  • Devin (ALLOW_ACCESSORY_MODE not flipped). Flag flips to true. The previous comment ("Flip to true in the same change that lands the tray") is replaced with one that explains the post-flip UX (signed-out + zero windows → menu-bar-only, tray is the always-available re-entry point) — exactly the contract dock.ts:52 was holding for the next reviewer.

Behavior changes are net positives

The PR body calls these out, and they're correct:

  1. second-instance previously called focusMainWindow() which no-op'd when the window was destroyed → second-launch could appear to do nothing if the user had closed the window. Now ensureVisible() recreates. Real bug, real fix.
  2. activate previously only acted when BrowserWindow.getAllWindows().length === 0 → Dock-clicking on the app while it had visible-but-unfocused state did nothing, despite Swift bringing the window forward. Now matches Swift.
  3. Accessory-mode transition on signed-out + no-windows. Drops the Dock icon (per LUM-1966 design); the tray remains as the entry point.

Tray gotchas handled correctly

  • Template image (setTemplateImage(true), black-on-transparent bitmap, anti-aliased ring) — macOS auto-inverts for dark mode + pressed state. No-asset PR per the build-from-bitmap approach; the comment names the artwork follow-up.
  • setIgnoreDoubleClickEvents(true) — fast single clicks register as two separate clicks instead of being swallowed as a double-click.
  • Module-scope trayInstance — keeps the JS handle alive so Node's GC doesn't reap the Tray and leave a ghost NSStatusItem.
  • No tray.setContextMenu(menu) — left and right click both opening the same menu would have collapsed the tray's two intentional gestures. popUpContextMenu from the right-click listener instead, comment explicitly cites the issue.

dock.ts computePolicy purification

Unannounced but real: computePolicy becomes a pure function of (visibleWindows, signedIn, allowAccessoryMode). Test surface is now a 2x2x2 truth table instead of needing to stand up a BrowserWindow registry. Side effect: formatBadge also picks up export for the same reason. Worth keeping for future Electron-main test work.

Small non-blocking observations

  1. No explicit tray.destroy() on before-quit. In production this doesn't matter — the NSStatusItem is released when the process exits. In dev with main-process hot reload (bun run dev rebuilds), the old JS reference is GC-eligible but popUpContextMenu can sometimes leave a ghost icon for a beat. Worth a one-liner: app.on("before-quit", () => trayInstance?.destroy()) next to the daemon-stop call.
  2. devServerUrl() is called twice per createWindow — once in loadUrl() and once in installSameOriginNavigationGuard. Each call re-reads process.env.VELLUM_DEV_URL. Tiny; could be hoisted to a const at the top of createWindow so both consumers see the same string. (If a developer changes the env var mid-process, the two calls would otherwise disagree about origin — pathological but not impossible.)
  3. APP_PROTOCOL / APP_HOST now duplicated in index.ts + main-window.ts. The inline comment explicitly cites this and frames it as "lift if a third caller arrives." I'd argue the third caller is already in flight (the about/settings windows route through the same protocol via #32622) — but the constants are stable enough that drift is unlikely to bite before PR 2/3 of this series.

Merge gate

  • Vex: ✅ this approval at 1112e1c5f0.
  • Codex: P2 at f953cf9c97 substantively closed in 1112e1c5f0 (focus-before-dispatch via ensureMainWindow, asserted by test). Bot's last pass predates the fix — a fresh @codex review at HEAD would close the gate cleanly.
  • Devin: "1 issue" at f953cf9c97 substantively closed in 1112e1c5f0 (ALLOW_ACCESSORY_MODE = true). Same staleness — fresh @devin-ai review this PR at HEAD would formalize closure.
  • CI: 6/6 green.

Two-bot review cycle as a model for future reviewers: when the findings point at a structural absence rather than a local bug, the right second commit is the structural change — not the local patch. This PR is that pattern.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1112e1c5f0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/macos/src/main/tray.ts Outdated
Comment thread apps/macos/src/main/dock.ts
Copy link
Copy Markdown
Contributor Author

Three findings addressed in d6b3a27:

Codex P2 (visibleWindowCount misses hidden windows): filter now also checks win.isVisible(). Added per-window show and hide listeners alongside the existing closed, so the policy refreshes on visibility transitions (not just create/destroy). The accessory-mode transition now actually fires when the user hides the main window from the tray while signed out.

Codex P2 (tray command race against renderer ready): ensureVisible() now returns Promise<void> resolved on the new window's did-finish-load. TrayHandlers.ensureMainWindow returns the same shape, and the New / Current Conversation tray menu items await it before dispatching. Residual race called out in the JSDoc: did-finish-load fires after parse but before React mounts — gap is ~ms in practice, and a proper renderer-ready IPC handshake is a bigger ticket. For the tray's click rate this is good enough.

Vex observations folded in same commit:

  • app-config.ts extracted — APP_PROTOCOL / APP_HOST + RENDERER_BASE_PROD + getDevRendererBase() shared across index.ts, main-window.ts, about.ts (the third caller was the trigger).
  • devServerUrl resolved once per createWindow so loader + nav guard see a consistent string.
  • tray.destroy() on before-quit to avoid ghost icons during dev hot-reload.

All 7 test files green.


Generated by Claude Code

@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6b3a273ab

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

trackWindowState("main", win);

armRenderReady();
win.webContents.once("did-finish-load", () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve tray readiness after focusing new windows

When the main window has been closed while an auxiliary window such as About is still focused, the tray's New/Current Conversation handlers await ensureMainWindow() and then call dispatchToFocused(). This promise resolves on did-finish-load, but the freshly-created window is still shown/focused later from ready-to-show, so the dispatch can run while About remains the focused/first window and the chat command is dropped. Please resolve the await only after the new main window has been shown/focused, or explicitly focus it before resolving.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Latest Codex P2 (await resolves while About still holds focus) addressed in 1962044.

After the prior commit awaited did-finish-load before the tray's dispatch, a residual race remained: ready-to-show typically fires after did-finish-load, so the new main window was shown only after the await had already resolved. If About held focus at that moment, dispatchToFocused (which picks by getFocusedWindow) landed the command on About and dropped it.

Fix: renderReady now resolves only when BOTH events have fired:

  • did-finish-load — renderer is loaded.
  • ready-to-show — at which point we also win.focus() (in addition to win.show()). By the time the await resolves, focus has transferred to main, so dispatchToFocused targets it.

Order doesn't matter — whichever fires first waits for the other. Both branches covered in new tests.

The two prior Codex P2s on this PR (visibleWindowCount on hidden windows; tray command vs renderer-ready) are also now closed at HEAD 1962044ed3 per the explanations in the previous reply.


Generated by Claude Code

@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1962044ed3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/macos/src/main/main-window.ts Outdated
// `VELLUM_DEV_URL` is mutated mid-process.
const isDev = !app.isPackaged;
const devBase = isDev ? getDevRendererBase() : null;
const loadTarget = devBase ?? `${RENDERER_BASE_PROD}/index.html`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load assistant route instead of /index.html

In packaged builds this constructs app://vellum.ai/assistant/index.html. The protocol handler may serve index.html for that request, but the browser URL remains /assistant/index.html; apps/web/src/routes.tsx only defines the app root at /assistant and then catches unknown /assistant/* paths with NotFound, so the Electron main window opens to the NotFound route instead of the app. Load RENDERER_BASE_PROD itself (or another real app route) rather than appending /index.html.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Codex P1 (prod main-window loads /index.html → NotFound) addressed in 5080dc8.

The protocol handler in index.ts already falls back to index.html content for paths without an extension, so loading RENDERER_BASE_PROD directly (app://vellum.ai/assistant) serves the SPA but keeps the browser URL at /assistant — which is where React Router's app-root route matches. Appending /index.html left the URL at /assistant/index.html, which the catch-all caught as NotFound. Pre-existing latent bug — the prod load path isn't exercised in dev so this hadn't surfaced before now.


Generated by Claude Code

claude added 6 commits May 29, 2026 22:54
…UM-1965)

PR 1 of the tray work — lands the icon, click toggle, and context
menu. Pulse-animation state machine and main-window-sizing change
follow in PR 2 / PR 3 so each lands in a reviewable diff.

Capability today:
- Menu-bar icon (programmatic template image — outlined circle
  placeholder; real avatar art lands with the pulse PR).
- Left click toggles the main window: recreate if destroyed, hide
  if visible+focused, focus otherwise.
- Right click (or Ctrl-click) pops a context menu with the canonical
  tray actions: New Conversation, Current Conversation, Show / Hide
  Main Window, About Vellum, Quit.
- Tooltip set to the app name.

Implementation notes:
- src/main/tray.ts holds the install + menu + icon builder. Module
  is decoupled from index.ts's lifecycle by taking
  `{ toggleMainWindow, openAbout }` handlers — keeps the main window
  reference in index.ts, where it belongs, and makes the tray module
  unit-testable without standing up a BrowserWindow.
- `installed`-flag idempotent install per the apps/macos PATTERNS.md
  bootstrap convention. Module-scope `Tray` reference held to keep
  the JS object alive past the function — without it, GC collects
  the handle and the icon disappears from the menu bar even though
  the underlying NSStatusItem is still alive.
- No `tray.setContextMenu()`: with it, left and right click both open
  the same menu. Instead: `click` + `right-click` listeners and
  manual `popUpContextMenu` from the right-click path. Matches the
  Linear / menu-bar-app pattern.
- `setIgnoreDoubleClickEvents(true)` so fast single clicks fire as
  two `click` events instead of being coalesced into a swallowed
  double-click on macOS.
- Programmatic template icon (16x16 antialiased outlined ring) so no
  binary assets ship in this PR. Switches to `createFromPath` when
  the assistant-avatar artwork lands.
- LUM-1962 ("blocked by") is actually already in main as the typed
  command bus at src/main/commands.ts — tray dispatches New/Current
  Conversation through it the same way the application menu does.

Tests (apps/macos/src/main/tray.test.ts, 5 cases):
- Single Tray construction, double-click suppression, tooltip
  installation, click + right-click registration — across 3
  `installTray()` calls (idempotency).
- Template-image flag set so macOS dark-mode inversion works.
- Left-click routes through the `toggleMainWindow` handler.
- Right-click pops the context menu.
- Menu template contains the canonical actions and a `role: "quit"`
  Quit item.

Test-setup shim grows Tray + nativeImage entries.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
…lip ALLOW_ACCESSORY_MODE

Audit-driven follow-up to the original tray PR (f953cf9).

## Why

The original tray PR added a 4th variant of "make main window visible"
to index.ts on top of the 3 already there. Every future
window-manipulating ticket (thread pop-outs, deep links, command
palette, hotkey handlers, ambient agent activation) would have added
its own variant. The main window was the one piece of UI that didn't
follow the module-scope-state + installX + named-exports pattern that
dock.ts / about.ts / settings.ts already use.

Codex's focus-before-dispatch finding was the visible bug — the deeper
issue was scattered window management. Patching in place would have
ratcheted the complexity. Extracting now is small; later it's bigger.

## What

### `src/main/main-window.ts` (new)

Owns the main window's lifecycle. Module-scope mainWindow is private.
Public API:

  - `ensureVisible()` — the primitive. Recreate if destroyed, restore
    from minimize, show, focus. Every caller composes from this.
  - `hide()` / `toggleVisibility()` / `isVisibleAndFocused()` / `current()`
  - `installMainWindow()` — bootstrap, called once from `whenReady`.

createWindow, the will-navigate same-origin guard, the dev/prod URL
strategy, and trackWindowState wiring all move in.

### `src/main/index.ts` (slimmed by ~85 lines)

Drops 4 helpers, the mainWindow let, and the URL-resolution constants.
whenReady calls installMainWindow(); activate calls ensureVisible();
second-instance calls ensureVisible() (behavior change called out below).

### `src/main/tray.ts`

TrayHandlers gains `ensureMainWindow`. The New / Current Conversation
menu items now call it before dispatchToFocused — Codex P2 fix.

### `src/main/dock.ts`

ALLOW_ACCESSORY_MODE flips from false to true. The previous comment
explicitly documented this: "Flip to true in the same change that
lands the tray." Devin's finding addressed.

## Behavior changes called out

1. second-instance previously no-op'd when main was destroyed; now
   recreates. Net fix.
2. activate previously only acted when main was null/destroyed; now
   ALSO brings an existing window to front. Matches Swift.
3. With accessory mode on, signed-out + all-windows-closed transitions
   the app to menu-bar-only. Intended behavior from LUM-1966.

## Tests

- main-window.test.ts (new) — primitives exercised across the matrix.
- tray.test.ts — handlers updated; new test asserts conversation items
  surface the window before dispatching.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
… extraction

Folds in three Codex / Vex findings into one commit since they're all
about the same architectural seam — the main window's lifecycle and
how dependent surfaces (tray, dock) compose around it.

## Codex P2 — visibleWindowCount misses hidden windows

`dock.ts`'s `visibleWindowCount` previously filtered on
`isDestroyed()` only. Hiding the main window via the tray keeps the
BrowserWindow alive but invisible, so the count stayed at 1 and
the policy never transitioned to accessory mode (which only fires
when count === 0 + signed out + ALLOW_ACCESSORY_MODE).

Fix: filter additionally on `win.isVisible()`. Add per-window `show`
and `hide` listeners alongside the existing `closed` so the state
machine refreshes on visibility transitions, not just on
create/destroy.

## Codex P2 — tray command race against renderer ready

When the main window is destroyed and the user picks New / Current
Conversation from the tray, `ensureMainWindow()` returns
synchronously after `new BrowserWindow` even though the preload
script and the `useVellumCommands` listener haven't subscribed yet.
The immediate `dispatchToFocused(...)` then sent the command to a
not-yet-ready renderer and dropped it.

Fix: `ensureVisible()` now returns `Promise<void>` resolved on the
window's `did-finish-load`. `TrayHandlers.ensureMainWindow` returns
the same shape, and the New / Current Conversation menu items
`await` it before dispatching.

Residual race: `did-finish-load` fires after parse but before React
mounts. In practice the gap is ~ms — a proper renderer-ready IPC
handshake is a bigger ticket. For the tray's click rate this is
good enough; called out in the JSDoc.

## Vex observation — `app-config.ts` extraction

`APP_PROTOCOL` and `APP_HOST` had three callers across `index.ts`,
`main-window.ts`, and `about.ts` (the third one arrived in #32622
with about.ts's URL composition). The previous "small surface, lift
when a third caller arrives" comment is now actionable.

`apps/macos/src/main/app-config.ts` holds the constants plus a
shared `RENDERER_BASE_PROD` + `getDevRendererBase()` helper. About.ts
sheds its bespoke dev URL fallback + trailing-slash strip (now in
the helper). main-window.ts resolves the dev URL once per
`createWindow` so the loader and the navigation guard see a
consistent string even if `VELLUM_DEV_URL` is mutated mid-process.

## Vex observation — tray destroy on before-quit

`app.on("before-quit", () => trayInstance?.destroy())` inside
`installTray`. In production the OS releases the NSStatusItem when
the process exits; in dev with main-process hot reload, this avoids
a ghost menu-bar icon for a beat between reloads.

## Tests

- `main-window.test.ts` — `webContents.once` added to the stub for
  the `did-finish-load` await. All 16 cases still cover the API.
- `tray.test.ts` — handlers updated to `mock(() => Promise.resolve())`
  for `ensureMainWindow`; `app.on` added to the electron mock for
  the new before-quit subscription.
- All 7 test files green.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
…ady-to-show

Codex P2 — after the prior commit awaited `did-finish-load` before
the tray's dispatch, a residual race remained: if About was focused
when the user clicked the tray's New Conversation, the new main
window's `ready-to-show` fires *after* `did-finish-load`, so the
dispatch ran while About still held focus. `dispatchToFocused` picks
by `getFocusedWindow`, so the chat command went to the wrong window
and was dropped.

Fix: `renderReady` resolves only when BOTH events have fired:

  - `did-finish-load` — the renderer is loaded.
  - `ready-to-show` — at which point we now also call `win.focus()`,
    not just `win.show()`. By the time the await resolves, focus has
    transferred to main, and `dispatchToFocused` lands on it.

Order doesn't matter — whichever event fires first waits for the
other. Both branches covered in the new tests.

Test additions in `main-window.test.ts`:
- Promise waits for did-finish-load first then ready-to-show.
- Symmetric: ready-to-show first, then did-finish-load.
- ready-to-show calls both show() and focus() so dispatchToFocused
  targets the new window.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
Codex P1 — in packaged builds the main window was loading
`app://vellum.ai/assistant/index.html`. The `app://` protocol
handler did serve `index.html` content for that path, but the
browser URL stayed at `/assistant/index.html`. React Router's
top-level routes only declare `/assistant` itself; the `*` catch-all
inside the `/assistant` subtree picks up everything else with
`NotFound`. So the prod main window would have opened to the
NotFound route instead of the app.

Fix: load `RENDERER_BASE_PROD` directly (`app://vellum.ai/assistant`).
The protocol handler's no-extension fallback in `index.ts` serves
`index.html` content; the browser URL stays at `/assistant`; React
Router's `/assistant` route matches; the app loads.

Pre-existing bug — the prod load path isn't exercised in dev so
this hadn't bitten. Caught by Codex on the React-route audit.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
…tive bug sweep)

Audit-driven sweep after the latest Codex P1 — three real bugs that
would have surfaced under load, plus a small API addition that closes
the focus-transfer race class for good.

## Per-window readiness via WeakMap (P0)

The prior `let renderReady` + `let resolveRenderReady` lived at module
scope. Two failure modes:

  1. **Concurrent create cross-resolution.** If a second `createWindow`
     ran before the first's events fired, the second `armRenderReady`
     overwrote the first window's resolver. The first window's
     `did-finish-load` would then resolve the SECOND window's promise
     prematurely (before its renderer loaded).

  2. **Stale-promise hangs.** The existing-window path of
     `ensureVisible` returned the module-scope `renderReady` from the
     previous load. If that load never completed (window destroyed
     mid-load), the next caller awaited an unresolved promise forever.

Fix: per-window state keyed by `WeakMap<BrowserWindow, ReadyState>`.
Each `createWindow` arms a fresh state owned by its window. The
existing-window re-show path returns an already-resolved sentinel
(its readiness was satisfied on the original load).

## Resolve readiness on `closed` (P0)

Network failure during load, user quit mid-load — neither
`did-finish-load` nor `ready-to-show` fires, but the await would
hang. Now the `closed` handler resolves the pending state so the
caller unblocks. The follow-up `dispatchToMain` then sees no main
window and no-ops — which is the right semantics: the window is
gone, nothing should happen.

## Tray dispatches by reference, not by focus (P2)

`dispatchToFocused` picks via `getFocusedWindow()`. After
`win.focus()`, the OS doesn't deliver the focus change
synchronously — on macOS, with the app potentially backgrounded
when the tray is clicked, even a perfectly-timed await can still
race with focus delivery and land the dispatch on About or
whichever window held focus.

Added `dispatchToMain(command)` in `main-window.ts`: looks up the
main window by reference and sends `vellum:command` directly. The
tray's New / Current Conversation menu items now call it instead of
`dispatchToFocused`. The focus-transfer race goes away by
construction — we don't ask "who's focused?", we say "send it to
main."

`dispatchToFocused` stays as-is in `commands.ts` for the application
menu, where "focused" is the right semantics.

## Tests

- `main-window.test.ts` — new readiness-gate test for
  destroyed-before-ready (window closed before either event fires →
  awaiter unblocks). New `dispatchToMain` describe block: dispatches
  via webContents.send, no-ops when no window, no-ops on destroyed.
- `tray.test.ts` — mocks `./main-window` for `dispatchToMain`,
  conversation-items test asserts both `ensureMainWindow` is awaited
  AND `dispatchToMain` is called with the right command kinds.
- All 7 test files green.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
@ashleeradka ashleeradka force-pushed the claude/lum-1965-tray-icon branch from 5080dc8 to ab43bc6 Compare May 29, 2026 22:59
Copy link
Copy Markdown
Contributor Author

Proactive bug sweep — three real bugs fixed in ab43bc63d2

Rebased onto current main and ran a deep walkthrough rather than waiting on Codex to find these one at a time. Three real findings, all fixed; four false positives explained at the bottom.

P0 — Per-window readiness state (module-scope cross-resolution)

renderReady / resolveRenderReady were module-scope. Two failure modes:

  1. Concurrent create cross-resolution. A second createWindow running before the first's events fired would have the second armRenderReady() overwrite the first window's resolver. The first window's did-finish-load would then resolve the SECOND window's promise prematurely.
  2. Stale-promise hangs. The existing-window path returned the module-scope renderReady from the previous load. If that load never completed (window destroyed mid-load), the next caller awaited an unresolved promise forever.

Fix: per-window state via WeakMap<BrowserWindow, ReadyState>. Each createWindow arms a fresh state owned by its window. The existing-window re-show path returns an already-resolved sentinel.

P0 — Resolve readiness on closed

Network failure during load or user quit mid-load → neither event fires → await hangs forever. Now the closed handler resolves the pending state so callers unblock. The follow-up dispatchToMain sees no main window and no-ops — right semantics.

P2 — Tray dispatches by reference, not by focus

dispatchToFocused picks via getFocusedWindow(). After win.focus(), the OS doesn't deliver focus synchronously — on macOS with the app potentially backgrounded when the tray is clicked, the dispatch could still land on About or whichever window held focus.

Added dispatchToMain(command) in main-window.ts: looks up the main window by reference and sends vellum:command directly. Tray's New / Current Conversation menu items now call it. The focus-transfer race goes away by construction — we don't ask "who's focused?", we say "send to main." dispatchToFocused stays for the application menu, where focused IS the right semantics.

Tests

  • main-window.test.ts: destroyed-before-ready awaiter unblocks; dispatchToMain sends vellum:command, no-ops with no window, no-ops on destroyed window.
  • tray.test.ts: mocks ./main-window; conversation-items test asserts both ensureMainWindow is awaited AND dispatchToMain is called with the right command kinds.
  • All 7 test files green.

False positives investigated and skipped

  • Dock listener "leak": when a BrowserWindow is destroyed, its EventEmitter goes with it. show / hide listeners die with the window. Not a leak.
  • Hot-reload double before-quit: dev-only with main-process hot reload, which we don't have. Pattern matches the rest of the package.
  • About-route matcher: React Router v7 data mode matches by path with most-specific wins. /assistant/about is declared as its own top-level route, so the /assistant auth middleware doesn't apply to it. Verified.
  • URL composition fragility: replace(/\/+$/, "") + ${base}${path} is correct for every sensible input including double trailing slashes.

Generated by Claude Code

@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab43bc63d2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/macos/src/main/main-window.ts Outdated
Comment on lines +218 to +221
// Existing window: its readiness was satisfied on the original
// load. Re-show + re-focus are synchronous OS calls; nothing to
// wait for.
return ALREADY_READY;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for in-flight window readiness before tray commands

When the main window already exists but is still loading, this branch returns an already-resolved promise even though the ReadyState promise has not resolved yet. This happens during startup/recreation if the user chooses “New Conversation” or “Current Conversation” from the tray before did-finish-load and ready-to-show; the tray handler awaits ensureMainWindow() and then immediately sends vellum:command, so the command can still be dropped by the renderer. Return the existing window's pending ready promise when its load/show gate is not satisfied yet.

Useful? React with 👍 / 👎.

…ness, not ALREADY_READY

Codex P2 — the existing-window branch was returning `ALREADY_READY`
unconditionally, but if the window was JUST created and its
`did-finish-load` + `ready-to-show` hadn't both fired yet, that's
the wrong answer. The tray's `await` would resolve immediately
and `dispatchToMain` would land on a renderer that hadn't yet
subscribed to `vellum:command`.

Fix: look up the existing window's stored `ReadyState` and return
its promise. If both events have already fired, the promise is
resolved and the await is free. If not, the await waits for the
gate the same way it does on a fresh `createWindow`.

Test added: a second `ensureVisible` against an in-flight window
shares the same readiness gate and resolves only when the events
fire.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
Copy link
Copy Markdown
Contributor Author

Codex P2 (existing-window ensureVisible returns ALREADY_READY even when the window's load gate hasn't fired yet) addressed in 1bbc6c3.

The branch now returns readyStates.get(mainWindow)?.promise ?? ALREADY_READY — if the window was just created and its events haven't both fired, the await waits on the same gate as a fresh createWindow. If both fired, the stored promise has already resolved and the await is free. Test added pinning that two concurrent ensureVisible calls against an in-flight window share the same readiness.


Generated by Claude Code

@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bbc6c3f7b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

* Renderer-base URL for the packaged app. Auxiliary windows append
* their own subpath (`/about`, future `/conversations/<id>`, etc.).
*/
export const RENDERER_BASE_PROD = `${APP_PROTOCOL}://${APP_HOST}/assistant`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serve the /assistant prefix before packaging

In packaged builds this base causes the loaded HTML to request assets under /assistant/assets/... because apps/web/vite.config.ts sets base: "/assistant/", but the app:// handler in apps/macos/src/main/index.ts joins the full URL pathname directly under rendererRoot and only serves files that exist there. Since the web build emits files under rendererRoot/assets/... (not rendererRoot/assistant/assets/...), the main JS/CSS requests 404 and the packaged Electron app opens a blank/broken renderer unless the protocol handler strips the /assistant mount prefix or the files are copied under that prefix.

Useful? React with 👍 / 👎.

Codex P1 — packaged builds load `app://vellum.ai/assistant` and
the returned HTML refers to assets at `/assistant/assets/index.js`
(because `apps/web/vite.config.ts` sets `base: "/assistant/"`).
The protocol handler joined those URL paths directly under
`rendererRoot`, looking for `rendererRoot/assistant/assets/index.js`
— but the renderer files on disk live at `rendererRoot/assets/...`,
NOT under a `/assistant/` subdirectory. Every asset 404'd → blank
renderer in prod.

Fix: `resolveAppProtocolPath` takes an optional `mountPrefix`.
`index.ts`'s protocol handler passes `"/assistant"`, so requests
matching `/assistant` (exact) or `/assistant/<rest>` map to
`rendererRoot` (exact) and `rendererRoot/<rest>` respectively.
Other paths pass through unchanged so things outside the mount
(favicon, future top-level resources) still work.

Pre-existing latent prod bug — dev served via Vite at port 5173
doesn't go through the protocol handler so this hadn't surfaced.

Tests added (`app-protocol.test.ts` — 6 cases under "mount-prefix
stripping"):
- `/assistant` → renderer root
- `/assistant/<rest>` → `rendererRoot/<rest>`
- Deep nested paths
- Non-mount top-level paths pass through
- Prefix-confusion bait (`/assistantfoo/`) doesn't strip
- Traversal guard still fires (URL collapse + path-normalize chain
  still apply, no new attack surface)

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
Copy link
Copy Markdown
Contributor Author

Codex P1 (/assistant mount prefix vs renderer file layout) addressed in bc8184e.

resolveAppProtocolPath now takes an optional mountPrefix. The protocol handler in index.ts passes "/assistant":

  • /assistant (exact) → renderer root.
  • /assistant/<rest>rendererRoot/<rest> (so /assistant/assets/index.js resolves to rendererRoot/assets/index.js, which is where Vite emits assets).
  • Non-mount paths pass through unchanged (/favicon.ico etc.).
  • Prefix-confusion bait (/assistantfoo) doesn't strip — exact-match or mount + "/" only.

Pre-existing latent prod bug — dev serves through Vite at :5173 which doesn't go through the protocol handler, so this hadn't surfaced.

6 tests added under "mount-prefix stripping" covering: exact root mapping, deep paths, top-level passthrough, prefix-confusion, traversal-guard still firing.


Generated by Claude Code

…BrowserWindow count

Audit-driven architectural fix on top of the bug sweep.

## What's wrong (two findings folded together)

**Dock-About coupling.** `visibleWindowCount()` filtered on
`!isDestroyed() && isVisible()` but counted every BrowserWindow,
About included. Signed-out + main hidden + user opens About →
policy flips to `regular` → dock icon appears. Close About →
policy flips back to `accessory` → dock icon disappears. The dock
icon flickered on auxiliary-window lifecycle, which it shouldn't.

We'd already incrementally patched this twice (added `isDestroyed`
filter, then `isVisible` filter). A third filter for "is this
main" would be a third band-aid. The deeper issue is the
abstraction — the policy is conceptually about THE MAIN window,
not about generic window counts.

**`applyPolicy` interleaving race.** Two rapid policy transitions
could interleave: the first awaits `dock.show()`, the second
(`accessory`) runs synchronously and sets the activation policy,
then the first resumes and runs `setActivationPolicy("regular")`
— stomping the newer policy. The OS surfaces (dock visibility
vs activation policy) ended up out of sync.

## What the right shape is

1. `computePolicy` becomes `(mainVisible: boolean, signedIn,
   allowAccessoryMode) → policy`. Boolean, not count. Even the
   test matrix simplifies.

2. `dock.ts` subscribes to a typed event from `main-window.ts`:
   `onMainWindowVisibilityChange(callback)`. Auxiliary windows
   don't fire it. Dock no longer needs to identify "main" inside
   a generic `browser-window-created` listener — which couldn't
   have worked anyway, since `browser-window-created` fires
   inside `new BrowserWindow(...)` SYNCHRONOUSLY, before
   `main-window`'s `mainWindow = win` assignment lands.

3. `applyPolicy` re-checks `state.policy` after the awaited
   `dock.show()` so a concurrent `accessory` transition can't
   be clobbered. One-liner.

## Files

- `main-window.ts`: exports `onMainWindowVisibilityChange`. Wires
  `show`/`hide` listeners that fire the signal. The `closed`
  handler also fires after nulling `mainWindow`, so
  `isMainWindowVisible()` queries see the updated state.
- `dock.ts`: imports `current` + `onMainWindowVisibilityChange`
  from main-window. Drops the BrowserWindow.getAllWindows() scan
  and the `app.on("browser-window-created")` subscription.
  `computePolicy` takes `mainVisible: boolean`. `applyPolicy`
  re-checks policy after the await.
- `dock.test.ts`: `computePolicy` matrix updated for boolean
  signature (simpler — 2×2×2 → 5 named cases). Local mock of
  `./main-window` since the new dependency chain transitively
  imports `electron-store`.

All 7 test files green.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
Copy link
Copy Markdown
Contributor Author

Audit-driven architectural fix — closes the last two real findings

The bug-hunt found two more real issues; both folded into 8eb44e1 as one architectural fix rather than two patches.

Dock-About coupling

visibleWindowCount() counted every BrowserWindow, About included. Signed-out + main hidden + user opens About → policy flips to regular → dock icon appears. Close About → policy flips back to accessory. The dock icon flickered on auxiliary-window lifecycle.

We'd already incrementally patched this twice (added isDestroyed filter, then isVisible filter). A third filter for "is this main" would be a third band-aid. The deeper issue is the abstraction — the policy is conceptually about the main window, not about generic window counts.

Fix:

  • computePolicy becomes (mainVisible: boolean, signedIn, allowAccessoryMode) → policy. Boolean, not count. The test matrix simplifies.
  • main-window.ts exposes onMainWindowVisibilityChange(callback). Wires show / hide / closed listeners that fire the signal. Auxiliary windows don't fire it.
  • dock.ts subscribes via that hook. Drops the BrowserWindow.getAllWindows() scan and the app.on("browser-window-created") subscription — which couldn't have correctly identified "main" anyway since browser-window-created fires synchronously inside new BrowserWindow(...), before main-window's mainWindow = win assignment lands.

applyPolicy interleaving race

Two rapid policy transitions could interleave: the first awaits dock.show(), the second (accessory) runs synchronously and sets the activation policy, then the first resumes and runs setActivationPolicy("regular") — stomping the newer policy. Dock visibility and activation policy ended up out of sync.

Fix: applyPolicy re-checks state.policy after the awaited dock.show() and bails if the policy has changed. One-liner.

Skipped (not real bugs)

  • About URL recomputation: hypothetical, requires mid-process env mutation.
  • Window-destroyed-mid-destroy: WeakMap + identity guard handles correctly.
  • before-quit test gap: documentation-quality, doesn't justify scope.

All 7 test files green. To my knowledge, every real bug from both deep walkthroughs is now closed.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-APPROVE at 8eb44e1570 — 8 commits of bug-hunt audit closed cleanly, dock-about coupling re-abstracted

6/6 CI green. My prior APPROVE was at 1112e1c5f0; 8 commits since, all addressing real bugs. Re-verified at HEAD against bot findings.

Codex findings — all 7 closed at HEAD

# Codex finding Anchor Closing commit Verified
1 P2 f953cf9c97 — focus before dispatch tray.ts 1112e1c5f0 (prior APPROVE)
2 P2 1112e1c5f0 — queue tray commands until recreated window ready tray.ts 06ac37a6ee (tray ready-await) ✓ tray click now await ensureMainWindow() before dispatchToMain
3 P2 8eb44e1570 dock.ts L62 — count only visible windows dock.ts 8eb44e1570 (computePolicy refactor) ✓ closed structurally — computePolicy now takes mainVisible: boolean, no getAllWindows() scan, auxiliary windows don't fire onMainWindowVisibilityChange. Codex's L62 anchor lands on ALLOW_ACCESSORY_MODE = true — code is gone, comment is stale anchor.
4 P2 8eb44e1570 main-window.ts L182 — resolve readiness after focusing main-window.ts 9656699aa3 (BOTH-gate readiness) maybeResolveReady() only fires when didFinishLoad && didShow. show() + focus() run in the ready-to-show listener BEFORE didShow = true, so the promise resolves AFTER focus is set.
5 P1 1962044ed3 — load assistant route not /index.html main-window.ts d0513f3ec4 ✓ now loads RENDERER_BASE_PROD directly; no /index.html suffix.
6 P2 ab43bc63d2 — wait for in-flight readiness main-window.ts 1bbc6c3f7b ✓ existing-window branch returns readyStates.get(mainWindow)?.promise ?? ALREADY_READY, not unconditional ALREADY_READY.
7 P1 8eb44e1570 app-config.ts L30 — serve /assistant prefix app-protocol.ts bc8184efd9 (mount-prefix strip) resolveAppProtocolPath(rendererRoot, url, "/assistant") strips the mount in the protocol handler. New app-protocol.test.ts suite covers the /assistant, /assistant/<rest>, /assistantfoo (no false-prefix-match), and traversal-through-mount cases.

The two architectural fixes in 8eb44e1570 (the audit-driven commit)

Boss's issue comment frames it correctly. Verified both:

1. Dock-About couplingvisibleWindowCount() had been incrementally patched twice (!isDestroyed, then isVisible); a third patch for "is this main" would have been a band-aid. The deeper fix is the abstraction: computePolicy now takes (mainVisible: boolean, signedIn, allowAccessoryMode). dock.ts subscribes via onMainWindowVisibilityChange exported from main-window.ts — auxiliary windows (About, future thread pop-outs) deliberately don't fire it. The dock no longer needs to identify "main" from a window list. Same fix observation Boss had on app.on("browser-window-created") — that hook fires synchronously inside new BrowserWindow(...) BEFORE mainWindow = win lands, so any identity check there is racy by construction. Now the visibility hook fires AFTER the assignment in createWindow, after every show/hide, and on closed.

2. applyPolicy interleaving racestate.policy = next was being mutated before the awaited dock.show(), leaving a window where a second concurrent applyPolicy("accessory") could update state.policy AND setActivationPolicy("accessory") while the first call was still awaiting, then the first call resumes and runs setActivationPolicy("regular") — stomp. Fix: re-check state.policy !== "regular" after the await and bail. One-liner, correct ordering.

Bonus wins that landed in this audit pass

  • app-config.ts extraction — APP_PROTOCOL/APP_HOST/RENDERER_BASE_PROD/getDevRendererBase() lifted to a single shared module. This is exactly the "third caller arrived" refactor my prior review flagged as a future-proofing concern (about.ts is the third caller after index.ts + main-window.ts). about.ts:aboutWindowUrl now composes from the shared constants instead of duplicating the trailing-slash strip + URL concat. ✓
  • dispatchToMain extracted — tray.ts no longer routes through the application-menu's dispatchToFocused (which uses getFocusedWindow() ?? getAllWindows()[0]). The tray click happens with the app potentially backgrounded, so OS focus delivery is async; targeting main by reference is unambiguous. Test asserts dispatchToMainMock receives { kind: "newConversation" } and { kind: "currentConversation" } in order AFTER ensureMainWindow resolves.
  • closed handler resolves the readiness promise — anti-hang for the destroyed-mid-load race (network failure during load, user quit mid-load). Caller's follow-up dispatch sees current() === null and no-ops. Correct semantics — "user closed the window, nothing should happen".
  • Per-window readiness via WeakMap<BrowserWindow, ReadyState> — fixes the cross-resolve race the prior module-scope resolveRenderReady had where two near-simultaneous createWindow calls could have the second's armRenderReady overwrite the first's resolver.

Test coverage

  • New app-protocol.test.ts — 78 lines, covers root, nested, traversal collapse via URL parser, malformed %ZZ → forbidden (not 500), startsWith(rendererRoot + sep) invariant, and the full /assistant mount-prefix strip suite including the sibling-shares-prefix case (/assistantfoo/bar doesn't match /assistant strip).
  • dock.test.tscomputePolicy 2×2×2 truth table (pure function, no Electron stand-up). Mocks ./main-window as current: () => null, onMainWindowVisibilityChange: () => undefined.
  • main-window.test.ts — 408 lines, full ensureVisible / hide / toggleVisibility / isVisibleAndFocused / readiness-gate / dispatchToMain / current / installMainWindow coverage. The readiness-gate suite is the load-bearing one: waits for BOTH events, resolves regardless of order, in-flight ensures share the same promise, destroyed-before-ready unblocks the awaiter.
  • tray.test.ts — updated to assert await ensureMainWindow() → dispatchToMain ordering + payload shape.

Non-blocking observations

  1. Codex inline anchors on dock.ts L62 and main-window.ts L182 are stale — they point at lines that no longer contain the original bug (the architectural fix moved the relevant code). Worth a 👍 on those specific Codex inlines to mark resolved so the comment thread reflects the close.
  2. commands.ts:dispatchToFocused still uses getFocusedWindow() ?? getAllWindows()[0] — that's the right shape for the application menu (it should target wherever the user is focused), so it stays. Just noting it as a structural divergence: tray targets main, menu targets focused. The comment in dispatchToMain documents the asymmetry well.
  3. The local-mode dev path with VELLUM_DEV_URL overrides — getDevRendererBase() is now the single source of truth for both windows. If anyone sets VELLUM_DEV_URL=http://localhost:5173/assistant/ with trailing slash, the strip handles it; double-slash regression unlikely.

Merge gate

6/6 CI green at HEAD. All 7 Codex findings closed across 8 commits. Codex's two newest findings (dock.ts L62, app-config.ts L30) were both raised AGAINST 8eb44e1570 but had already been closed BY 8eb44e1570 — the anchors are stale, the code at those lines doesn't contain the bug anymore. A fresh @codex review at HEAD will surface this clearly. Devin's last APPROVE/comment is from f953cf9c97 (very early); a fresh @devin-ai review this PR would also formalize closure.

PROCEDURE carry-forward: when a Codex inline points at a line number and the code at that line has been structurally replaced (not just locally patched), the comment anchor migrates to the closest surviving line in the same file — making the comment LOOK like it's against new code. Always check the line content matches the finding's claim before treating an anchor as a live bug. The fix is the structural change, not addressing the comment in place.


Recommend nighttime merge once Codex re-reviews at HEAD (per the nighttime merge rule). All real findings closed; the architectural state of dock/main-window/about coupling is materially better than at my last APPROVE.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@ashleeradka ashleeradka merged commit 7374940 into main May 30, 2026
6 checks passed
@ashleeradka ashleeradka deleted the claude/lum-1965-tray-icon branch May 30, 2026 00:06
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.

2 participants