Skip to content

feat(desktop): open sessions in dedicated windows - #6093

Open
YayoiNanoka wants to merge 14 commits into
apache:mainfrom
YayoiNanoka:feat/session-windows-upstream
Open

YayoiNanoka wants to merge 14 commits into
apache:mainfrom
YayoiNanoka:feat/session-windows-upstream

Conversation

@YayoiNanoka

@YayoiNanoka YayoiNanoka commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Maka's single workspace window makes it difficult to keep separate tasks visible at the same time. Opening additional windows must avoid two editors owning the same session, losing unsent content during a handoff, or cleaning up resources that another window has taken over.

Solution

  • Add Open in new window to session menus. A main-process coordinator assigns each logical session group—its parent, subagents, revisions, and side conversations—to one window. Selecting an already detached session focuses its existing window.
  • Reuse the conversation and workbar in a dedicated window without the session sidebar. An inward-arrow Open in main window button sits immediately before the workbar expansion button. New windows open beside Main on its current display and remain within the display's usable area.
  • Transfer existing drafts, attachment approvals, side-conversation cleanup leases, and native browser/terminal ownership together. Handoff snapshots live only in process memory and are released after adoption. Strict handoff failures roll back partial custody changes; attachment recovery remains scoped to its session and receiving renderer, including replacement of an empty side-conversation fork.
  • During ordinary close, retain valid content and report anything that cannot be retained. Cancel resumes the source; Close anyway completes native window closing without repeating failed attachment validation. If cancellation cannot return copy custody, its recovery snapshot remains available. Queued navigation and presentation commands recheck whether their destination is closing.
  • Resolve session groups from the existing catalog, fetching only missing members or ancestors. Preserve asynchronous back/forward navigation, unrelated drafts in Main, and final native window bounds.

Result

Users can work on separate tasks side by side while each session group has one active window. Detaching the current session leaves Main blank; returning it preserves its draft and workspace. Closing Main leaves session windows running. Closing a session window retains recoverable drafts while the desktop process remains alive; restarting opens only Main and does not restore child windows or handoff drafts.

This change adds no SQLite database or tables, durable draft storage, attachment lifetime extension, or Runtime Host execution/message protocol.

Screenshots

Production shell components rendered with Storybook fixture data.

Main window with the session sidebar:

Main window with session sidebar

Dedicated session window; the return-to-main icon sits before the workbar expansion button:

Dedicated session window and return-to-main control

Validation

  • Full workspace build and desktop typecheck passed, including main, preload, renderer, and stories.
  • The complete desktop test suite passed: 3,911 tests, no failures or skips.
  • The complete storage test suite passed: 1,648 passed, 8 skipped, no failures.
  • 349 focused tests passed, covering ownership, partial failure, close/cancel, draft and attachment handoff, copy cleanup, browser/terminal permissions, navigation, and upstream navigation integration.
  • Both real Electron multi-window tests passed on macOS: detach/return preserves native browser identity and restores a minimized owner; closing/recreating Main preserves the child and restart opens only Main.
  • Lint, formatting, renderer architecture against upstream 1751c399, app-shell hooks, locale hygiene, Windows test inventory, E2E budget, and desktop/UI Knip passed.
  • Independent subagent reviews reproduced and verified fixes for partial copy adoption, cancellation failure, and commands entering a closing window. No confirmed P1/P2 remains in the reviewed scope.
  • A final integration review verified the upstream merge, parent-session navigation, and internal links without finding P1/P2 regressions.
  • Native Windows/Linux execution was not run locally. Remote CI results are reported by this PR's checks.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex implemented the feature and tests, ran validation, and performed independent subagent review. Commits include the Generated-by: OpenAI Codex trailer. Human review and the merge decision remain pending.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck, and the affected suites pass locally
  • This PR changes behavior as described above
点击展开中文

问题

Maka 只有一个工作区窗口,不便于同时查看不同任务。增加窗口需要避免两个编辑界面同时持有一个 session、交接时丢失待发送内容,以及原窗口清理已被其他窗口接管的资源。

解决方式

  • 在 session 菜单中增加“在新窗口打开”。主进程协调器将每个逻辑 session 组(父会话、子 agent、修订版本和侧边对话)分配给唯一窗口。点击已经移出的 session 会聚焦其现有窗口。
  • 独立窗口复用对话和工作栏,不显示左侧 session 列表。“在主窗口打开”使用向内箭头图标,位于工作栏展开按钮左侧。新窗口在主窗口所在屏幕的旁边打开,并限制在屏幕可用区域内。
  • 一起交接现有草稿、附件授权、侧边对话清理归属,以及原生浏览器和终端的控制权。交接快照只保存在进程内存中,接管完成后释放。严格交接失败时回滚已完成的部分归属转移;附件恢复继续受 session 和接收 renderer 的权限约束,包括空侧边对话 fork 被替换的情况。
  • 正常关闭时保留有效内容,并说明无法保留的部分。“取消”恢复原窗口;“仍然关闭”完成原生窗口关闭,不重复已经失败的附件校验。如果取消时无法归还副本归属,会保留对应恢复快照。排队的导航和展示命令在实际执行时重新检查目标是否正在关闭。
  • 使用现有目录解析 session 组,只补查缺失的成员或祖先。保留异步前进/后退导航、主窗口其他 session 的草稿,以及最终原生窗口位置和尺寸。

实现效果

用户可以并排处理不同任务,每个 session 组仍只有一个活动窗口。移出当前 session 后,主窗口显示空白页;移回时保留草稿和工作区内容。关闭主窗口不会关闭其他 session 窗口。关闭 session 窗口后,只要桌面进程仍在运行,就保留可恢复草稿;重启仅打开主窗口,不恢复子窗口或交接草稿。

本次改动不新增 SQLite 数据库或表、草稿持久化、附件有效期延长,或 Runtime Host 的任务执行与消息协议。

截图

使用 Storybook 示例数据渲染实际产品组件。

包含左侧 session 列表的主窗口:

包含会话列表的主窗口

独立 session 窗口;返回主窗口图标位于工作栏展开按钮左侧:

独立会话窗口与返回主窗口按钮

验证

  • 完整工作区构建和桌面类型检查通过,覆盖 main、preload、renderer 和 stories。
  • 完整桌面测试集通过:3,911 项测试,没有失败或跳过。
  • 完整存储测试集通过:1,648 项通过、8 项跳过,没有失败。
  • 349 项针对性测试通过,覆盖归属、部分失败、关闭与取消、草稿和附件交接、副本清理、浏览器和终端权限、导航,以及上游导航集成。
  • macOS 上两项真实 Electron 多窗口测试通过:移出与返回保留原生浏览器实例并恢复最小化窗口;关闭和重建主窗口保留子窗口,重启只打开主窗口。
  • Lint、格式、相对上游 1751c399 的 renderer 架构、app-shell hooks、语言文案、Windows 测试清单、E2E 预算及 desktop/UI Knip 检查通过。
  • 独立 subagent review 复现并验证了部分副本接管、取消失败和命令进入正在关闭窗口的问题。审查范围内没有剩余已确认的 P1/P2。
  • 最后的集成 review 检查了上游合并、父 session 导航和内部链接,没有发现 P1/P2 回归。
  • 本地未运行原生 Windows/Linux 测试。远端 CI 结果见本 PR 的检查状态。

AI 使用

  • 未使用生成式工具作出实质性贡献:否。
  • 生成式工具作出实质性贡献:是。

OpenAI Codex 完成功能实现、测试、验证及独立 subagent review。提交包含 Generated-by: OpenAI Codex 标记。人工审查和合入决定尚未完成。

检查清单

  • 测试覆盖本次改动,并能在缺少对应实现时检测失败。
  • 本地 lint、格式、类型检查及受影响测试集通过。
  • 本 PR 会改变产品行为,具体见上方说明。

Coordinate exclusive session-group ownership across independent windows, preserve workspace drafts and resources through transfers, and cover lifecycle and recovery regressions.

Generated-by: OpenAI Codex
@github-actions github-actions Bot added the effort/XXL Over 2500 readable lines label Oct 14, 2026
Remove workspace SQLite persistence and periodic autosave, reuse the existing draft and attachment lifecycle, and restore ordinary cache and layout behavior. Release temporary snapshots after adoption or failed transfers and cover cancellation, timeout, and multi-host cleanup.

Generated-by: OpenAI Codex
Keep the handoff regression fixture within the renderer architecture import boundary.

Generated-by: OpenAI Codex
Use an inward arrow icon and place the action before the workbar toggle in both visual and keyboard order.

Generated-by: OpenAI Codex
Place new session windows on the main window's current display with a 32-DIP offset and clamp their bounds to its work area. Preserve existing-window placement and cover display edges, scaling, small screens, and moves during creation.

Generated-by: OpenAI Codex
Regenerate the Astryx file inventory and include desktop TSX test entries in Knip analysis.

Generated-by: OpenAI Codex
@YayoiNanoka
YayoiNanoka marked this pull request as ready for review October 14, 2026 07:57

@Astro-Han Astro-Han 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.

Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.

First review of d14c0c54a0ba78756de9ae032b208fed5f0684ef (10 commits on merge-base b9cac4e3d; +6346/-589, 132 files). There are no prior findings.

Scope

Area Files Lines
Main-process window management (registry, coordinator, group resolver, main-window.ts, quit/PiP/notifications) 12 +981 / -129
IPC/bridge (*-ipc-main.ts gates, preload, shared contract, runtime-host-boot.ts wiring) 15 +403 / -54
Renderer (apps/desktop/src/renderer, packages/ui, stories) 59 +1335 / -356
Storage/state handoff (workspace state store, attachment recovery, draft/side-chat handoff contracts, composer draft/attachment hooks) 14 +926 / -25
Tests (unit + 1 E2E spec) 25 +2654 / -12
Docs/inventory/config (surface inventory, renderer-architecture, e2e-budget, knip) 5 +39 / -9
Smoke scripts 2 +8 / -4
Licenses 0 0

I found no issue or Discussion where a maintainer asked for multi-window sessions. The PR links no issue, and searches of issues and Discussions for "new window", "multiple windows", "separate window" and "detach" returned nothing relevant. For a feature of this size, a short design discussion first would help, covering ownership model, draft custody and browser policy.

A minimal first version needs: the workspace window registry and multi-window-aware controller (broadcasts, dialog parents); the coordinator's group ownership and route/focus logic; session window creation and the "Open in new window" / "Open in main window" entries; and ownership gates on the existing per-session IPC. In-memory draft, attachment and side-chat handoff, PiP re-parenting and browser-view transfer can all follow later. A first version could refuse to detach while the source holds an unsent draft.

Suggested split (each piece can merge on its own):

  1. Refactor only, no behavior change: workspace-window-registry, the MainWindowController/WorkspaceWindowController split, broadcast to all workspace renderers, showOpenDialog(options, sender) parent resolution and quit isQuitting().
  2. Coordinator, group resolver, session-window renderer root, menu entries, and shell-run and browser ownership gates. Drafts stay with the source.
  3. Draft, attachment and side-chat handoff: the state store, recovery transfer, composer/staging hooks and sessions:branchFromTurn recovery IDs.
  4. Native resources: PiP owner rebind and browser view transfer/parking, with an explicit background-browser policy (see finding 1).

Findings

  1. P1: Browser views of any session that was ever moved between windows can be driven in the background, including clicks and typing, until the Host retires. transferSessions and releaseRenderer add the session to detachedSessions, and canRunInBackground now returns isHostActive(ref) for those sessions (browser-ipc-main.ts:107). createBrowserViewHost.canDrive checks canRunInBackground first, so browserActionAllowed's "shown + live viewport" rule is skipped for both observe and mutate. beginAction also takes the background path. Only retireTarget removes entries from the set. Before this PR, only WorkHub coordination sessions could run hidden. With the PR, the exemption covers:

    • a session moved back to the main window while the user views another session;
    • a minimized session window;
    • a session window that was closed, whose page is now parked on the invisible parkingParent.

    The PR flips the existing assertion in browser-ipc-main.test.ts from canDrive(mainKey, 'observe') === false to true after all owning windows close, so the change is deliberate. The PR description does not mention it, though, and it removes the "a backgrounded conversation is never drivable" invariant documented in automation-host.ts. Suggested fix: keep detached sessions under the normal visibility rule, using their owning window's visibility. Only keep a page alive while parked, not drivable.

  2. P2: A workspace window (main or session) cannot be closed while its draft holds a file-picker attachment more than 30 minutes old, or one whose file has since been deleted. The coordinator's close handler (session-window-coordinator.ts:103-124) always calls preventDefault() on a ready window. It then flushes and saves, and destroys the window only if the save succeeds. SessionWorkspaceStateStore.#prepareDraft re-resolves every approval attachment through prepareIngestItems/peekApproval, and that fails with source_expired after ATTACHMENT_APPROVAL_TTL_MS (30 min) or item_unreadable if the file is gone. The store test "a handoff snapshot does not extend source approval…" asserts exactly this rejection. On failure, the close handler resumes the window and shows an error dialog. The test "failed handoff … and failed normal close" asserts h.main.destroyed === false. Every later close attempt fails the same way until the user removes the attachment or quits through the menu or tray. On Windows/Linux without a tray, the window close button is the normal quit path. Before this PR, closing never depended on the draft being transferable. Suggested fix: make the final close best-effort. Drop or degrade attachments that cannot be transferred (or keep them only as names), but never block the close on custody of the draft.

  3. P2: Every sessions:changed event now triggers a full Host session-catalog read, even when no session window exists. emitSessionsChanged calls sessionWindows.refreshGroup(...) for every non-delete reason (runtime-host-boot.ts:2271). That leads to resolveGroup, which runs state.candidate.client.listSessions() (runtime-host-boot.ts:898), a paginated full catalog read that can fail with catalog_unstable while the catalog changes. During a run, the observer emits status-change on each snapshot change and message-appended on each tool_result and turn start/end (runtime-host-session-observer.ts:838-873). Each one now adds a catalog read on top of the renderer's own refresh, so cost grows with catalog size × run activity for all users, not only those using session windows. Suggested fix: skip the refresh when no session window is open, or when the session is already a known member. Otherwise, derive membership from the changed session's ancestry instead of re-listing.

P3 (not verified at runtime or no runtime impact)

  • Ownership is enforced only for shell runs and the embedded browser. sessions:send, stop and edit IPC accept any workspace renderer. Today only routing in the UI keeps two windows from acting on one session.
  • canUseSession is synchronous and only knows a group's members after refreshGroup. A side chat or subagent created inside a session window is briefly treated as main-owned, so terminal and browser calls from that window are rejected until the asynchronous refresh lands.
  • The coordinator calls preventDefault() before main-window.ts's own close listener runs, so that listener returns early, and destroy() emits no close event. As a result the final bounds write and clearShowFallbackTimer are skipped on a user close; only the 400 ms debounced save persists bounds.
  • A hung (unresponsive but not crashed) renderer makes close wait for the 20 s flush timeout and then stay open. readyWaiters for a closed window only clear on timeout. The groups/memberGroups maps grow for the whole process lifetime.
  • Closing the main window no longer disposes browser views. They stay parked in memory until Host retirement. onMainWindowClose no longer tears down the Computer Use overlay or PiP on macOS.
  • Native Windows/Linux runs were not done, per the PR body. Session windows reuse the main window's frame and titlebar overlay options, but the Windows maximize sync and titlebar overlay were only exercised through CI packaging.

Epoch: no packages/runtime-host protocol files are touched and the PR claims no epoch change, so there is no collision with the 221/222 claims. Note that origin/main is already at RUNTIME_HOST_COMPATIBILITY_EPOCH = 221.

Mergeability/CI: the PR is CONFLICTING. The only conflicting file is docs/astryx-surface-file-inventory.md (the totals line: main has 337 files, the PR has 340), which is trivial to resolve. On the head, test (run 38520095937), package (Release Windows, run 38520095905) and label all pass.

};
const canRunInBackground = (sessionId: string): boolean => {
const ref = parseDesktopSessionResourceKey(sessionId);
if (detachedSessions.has(sessionId)) return deps.isHostActive(ref);

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.

P1: any session ever transferred or parked stays in detachedSessions until retireTarget, so canDrive/beginAction admit observe and mutate actions while it is hidden, minimized, back in main behind another session, or parked after its window closed. This removes the shown+viewport requirement that only WorkHub coordination sessions were exempt from. Keep parked pages alive, but leave them under the normal visibility rule.

});
entry.window.on('close', (event) => {
if (deps.isQuitting() || !ready.has(windowId)) return;
event.preventDefault();

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.

P2: close is always prevented and only completes if flush+save succeed. #prepareDraft rejects approval attachments older than the 30-minute approval TTL (source_expired) or whose file is gone. The window then resumes and refuses every later close until the attachment is removed. Final close should be best-effort and not depend on custody of the draft.

...(extra?.turnId ? { turnId: extra.turnId } : {}),
};
if (sessionId && reason !== 'deleted') {
void sessionWindows.refreshGroup(desktopSessionKey({ ...scope, sessionId })).catch(() => undefined);

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.

P2: this runs a full client.listSessions() catalog read (line 898) for every non-delete sessions:changed, including per-run status-change/message-appended events, even when no session window exists. Gate it on session windows being open or the session being a new unknown member.

Keep draft, side-chat, browser, and terminal ownership aligned with the owning session window. Preserve recoverable close state, roll back partial copy adoption, and reject queued commands after closing starts.

Generated-by: OpenAI Codex
Preserve upstream internal links and session-window routing, and keep the parent-session breadcrumb in the navigation feature.

Generated-by: OpenAI Codex

@Astro-Han Astro-Han 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.

Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.

At 56741b71a4b47737a1e96219e033944989279eb1, the previous browser-permission P1 and both previous P2 findings are addressed. I found one new P2 in the generated surface inventory. This follow-up covers the changes since d14c0c54, including author commit 3971ba2a and the subsequent merge of main 1751c399; it is not a new full review of the original feature.

[P2] Regenerate the surface inventory totals. docs/astryx-surface-file-inventory.md:9 still says 340 files and 336 aligned files, although the five added surface rows bring the actual totals to 345 and 341. With freshly installed pinned dependencies, npm run astryx:surface-inventory exits 1 because the Markdown differs from the generator output. I checked an isolated export of the actual main parent with the same generator and dependencies: main passes with 340 files, while this head fails with 345. The totals line is the only generated difference. The CI workflow runs this check before Build (.github/workflows/ci.yml:276–281), so this head introduces a reproducible validation failure even though its remote CI job has not started. Run npm run astryx:surface-inventory:write and commit the regenerated artifact.

The previous findings are resolved as follows:

  • Transferred browsers no longer receive an unrestricted background exemption. The foreground-permission test passes after transfer for a visible owner, rejects observation and mutation when that owner is minimized or hidden, and permits use again after it becomes visible. Loading the previous browser module makes the minimized-owner assertion fail. The existing test also verifies denial after the last owning window closes.
  • Expired or missing picker attachments no longer trap ordinary window close. Valid attachments and unsent text are retained; unavailable items are reported, cancellation resumes the window, and explicit confirmation closes it through the native close event. Loading the previous state-store or coordinator module makes the corresponding regression fail.
  • Session-change events no longer refresh every window group and fetch the full Host catalog. Executing the actual boot functions with instrumented dependencies gives zero group refreshes for 100 events and zero full-catalog reads for 100 known-session resolutions; the previous functions perform 100 of each. A missing child uses one targeted session read. This does not claim that the existing catalog's own background synchronization performs no reads.

The clean workspace build, four Desktop type checks, normal renderer build, 210 focused tests, five URI-parser tests, strict renderer-architecture check with its 168 tests, 23 inventory-contract tests, and E2E-budget check pass. Both unmodified native Electron session-window tests pass on Linux: native browser identity and minimized-owner restoration survive detach/return, and closing/recreating Main preserves the child while restart opens only Main. I did not rerun the full repository suites or native Windows/macOS tests.

There are no Runtime Host protocol changes against main 1751c399; epoch 222 is unchanged. GitHub reports the branch as mergeable but blocked. The Windows packaging check passed; CI run 38767026059 remains queued. Those checks do not establish a green result for this head.

| `apps/desktop/src/renderer/features/session-navigation/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/session-navigation/ui/archived-task-scope.tsx` | shell-chrome-or-panel | Button, HStack, Selector, StackItem, Text, TextInput, VStack | aligned — uses Astryx (Button, HStack, Selector, StackItem, Text, TextInput, VStack) | aligned |
| `apps/desktop/src/renderer/features/session-navigation/ui/session-history-navigation.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-commands.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |

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.

[P2] Regenerate the inventory totals with these new rows

These five new surfaces are present in both the path inventory and the generated rows, but the totals at line 9 remain at the main parent's 340 files / 336 aligned instead of 345 / 341. npm run astryx:surface-inventory therefore exits 1 on this head; the actual main parent passes with the same generator and pinned dependencies. This check runs before Build in CI. Please regenerate and commit the artifact with npm run astryx:surface-inventory:write.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XXL Over 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants