Skip to content

fix(desktop): adopt the early window through an explicit state machine - #61800

Merged
Hona merged 1 commit into
devfrom
early-window-machine
Nov 4, 2026
Merged

Hona merged 1 commit into
devfrom
early-window-machine

Conversation

@Hona

@Hona Hona commented Nov 4, 2026

Copy link
Copy Markdown
Member

Summary

The desktop app shows its first window before the main process loads, then adopts it when the layers restore windows. This handoff lived in a module-level let pending, a mutable openExternal swapped on adoption, and takeEarlyWindow(). This PR moves it to one state machine in packages/desktop/src/main/windows/early-machine.ts and fixes two bugs.

Bugs

Trigger Before After
windowIds in opencode.settings starts with a bad entry (["", "win-2"], [42, "win-2"], [""]) The early window takes "" or a fresh UUID. The registry filters bad ids, so restore() destroys the visible window and opens a hidden one: the window flashes away. With [""], "" is registered as an id. Both read ids through persistedWindowIDs() and agree, so the early window is adopted
The user closes the first window while the bundle or layers load app.quit() runs, but import("./desktop") still runs. restoreWindows() cannot tell "closed" from "never created" and opens new windows during the quit The machine moves to closed. The entry module skips import("./desktop"), restore plans no window, and ApplicationLifecycle.restoreWindows opens none during a quit (restoresWindows)

State

type EarlyWindowState<T> =
  | { status: "none" }
  | { status: "pending"; id: string; entry: T }
  | { status: "adopted"; id: string; entry: T; openExternal: (url: string) => void }
  | { status: "closed" }
stateDiagram-v2
  [*] --> none
  none --> pending: early.created
  pending --> closed: early.closed / quit
  pending --> closed: restore.requested (destroyed) / quit
  pending --> adopted: restore.requested (id persisted) / restore-windows(adopt + create)
  pending --> none: restore.requested (id gone) / destroy-early, restore-windows(create)
  adopted --> none: early.closed
  closed --> closed: restore.requested / no windows
  none --> none: restore.requested / restore-windows(create)
Loading
  • external.opened opens links with shell.openExternal while pending, and with the logged handler from the layers once adopted. This replaces the mutable EarlyWindow.openExternal.
  • restoreEarlyWindow() returns the RestoreStep[] plan. windows/index.ts maps each step to create(id, entry?), and reveal-machine.ts (fix(desktop): reveal hidden windows on a bounded fallback and before recovery dialogs #61743) still starts an adopted window shown.
  • persistedWindowIDs() in registry.ts is the one parser of the stored list. It now takes Schema.Json | undefined, the store's own value type.

Deleted

  • let pending and takeEarlyWindow() in early.ts.
  • The mutable EarlyWindow.openExternal and its reassignment in windows/index.ts.
  • The inline Array.isArray(ids) && Predicate.isString(ids[0]) id choice.
  • The usable / usable.win.destroy() branching in restore().

Deviations from the design

  • No firstWindowID() helper: its only caller inlines persistedWindowIDs(...)[0] ?? randomUUID().
  • restore.requested carries the parsed ids, not the raw stored value.
  • restore.requested in closed emits no second quit. The close already quit, and a lifecycle that is stopping finishes the quit on its own.
  • No before/after screenshots: the flash needs a hand-edited settings file and a packaged launch, and no desktop e2e suite covers it. The transition tests replay both triggers.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" stroke="currentColor">
  <style>
    .early{animation:stay 3s ease-in-out infinite}
    .badge{animation:badge 3s ease-in-out infinite}
    @keyframes stay{0%,100%{opacity:.9}}
    @keyframes badge{0%,30%{opacity:0;transform:translateY(4px)}45%,85%{opacity:.7;transform:none}100%{opacity:0}}
    @media (prefers-reduced-motion:reduce){.badge{animation:none;opacity:.7}}
  </style>
  <g class="early">
    <rect x="70.5" y="6.5" width="99" height="59" rx="3" opacity=".6"/>
    <path d="M70.5 16.5h99" opacity=".4"/>
    <circle cx="76" cy="11.5" r="1.5" fill="currentColor" stroke="none" opacity=".5"/>
    <circle cx="81" cy="11.5" r="1.5" fill="currentColor" stroke="none" opacity=".5"/>
    <g opacity=".35" stroke-width="2"><path d="M80 28h50"/><path d="M80 38h72"/><path d="M80 48h40"/></g>
  </g>
  <g class="badge">
    <circle cx="160" cy="54" r="7" opacity=".8"/>
    <path d="M156.5 54l2.5 2.5 4.5-5" stroke-width="1.5"/>
  </g>
</svg>

@Hona
Hona requested a review from Brendonovich as a code owner November 4, 2026 21:39
Copilot AI balanced review requested due to automatic review settings November 4, 2026 21:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The change looks sound. I traced both triggers through the diff, and the new state machine covers each path: a bad first windowIds entry, and closing the first window before adoption, whether that happens before the import("./desktop") check or after the layers start. The closed-after-adoption and macOS dock-reopen paths behave as before. early-machine.test.ts and the lifecycle and window tests pass (70/70), and tsc -b is clean. I couldn't reproduce the flash itself, since it needs a packaged Electron launch.

The public API is unchanged: all of this is internal to the desktop main process, and the stored windowIds format stays the same.

No blocking concerns.

@Hona
Hona merged commit 2e5d757 into dev Nov 4, 2026
14 checks passed
@Hona
Hona deleted the early-window-machine branch November 4, 2026 22:25
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