From 32d3980eb33fe4636145465015599c42eee419cb Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:55:03 +0800 Subject: [PATCH 1/2] fix: close the window from the last conversation tab Model: gpt-6 --- .agents/docs/sessions-tabs-routing.md | 4 +- .../2026-09-21-last-tab-window-close.md | 34 +++++++++++ .../2026-09-21-last-tab-window-close.zh.md | 30 ++++++++++ .../src/components/sessions/AGENTS.md | 6 +- .../src/components/sessions/README.md | 2 +- .../components/sessions/session-detail.tsx | 2 +- .../sessions/session-tab-close-target.ts | 8 ++- .../tests/session-tab-close-target.test.ts | 33 +++++++++-- specs/desktop-windows.md | 57 +++++++++++++++++++ specs/desktop-windows.zh.md | 7 ++- 10 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md create mode 100644 specs/desktop-windows.md diff --git a/.agents/docs/sessions-tabs-routing.md b/.agents/docs/sessions-tabs-routing.md index 373875046..8d4d4b95d 100644 --- a/.agents/docs/sessions-tabs-routing.md +++ b/.agents/docs/sessions-tabs-routing.md @@ -35,7 +35,9 @@ this page is the full text of the rules summarised there. A lone parent Session tab is not draggable; enable tab drag only once a second visible tab exists. On desktop, Cmd/Ctrl+W is the native Close accelerator. Session-detail registers a tab closer: focused side panel or - conversation tab closes, including the parent. Close writes shared `isTabClosed`, + conversation tab closes, including the parent when siblings remain. With only + one conversation tab, the conversation region yields to window close without + changing shared tab state. Explicit tab × still closes the tab. Tab close writes shared `isTabClosed`, selects the next open neighbour (right then left), or enters a local draft. `?tab=empty` remains an entry sentinel: after hydration, reuse a local draft or create one and replace the URL. Mobile viewers remain active. With diff --git a/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md new file mode 100644 index 000000000..156af0eba --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md @@ -0,0 +1,34 @@ +# Close the window from the last conversation tab + +Status: implemented +Translation: current + +English | [中文](2026-09-21-last-tab-window-close.zh.md) + +## Abstract + +Cmd/Ctrl+W closed the sole conversation tab in a newly opened Session window, +leaving a draft instead of closing the window. The close resolver now yields to +native window close when one conversation tab remains, including a child or draft. +Focused side-panel tabs retain priority, and explicit tab × keeps its existing +lifecycle behavior. The same rule applies to primary and auxiliary windows. + +## Decision and evidence + +The resolver accepted but ignored the visible conversation count. It now returns +an explicit window target, which SessionDetail forwards to the existing shell +close fallback without calling the shared tab mutation. This avoids special-casing +window origin and preserves native main-window hide versus auxiliary-window close. +Multiple conversation tabs still close individually. + +This changes only shortcut routing; the [empty-tab deletion decision](2026-09-18-empty-tab-close-exact-delete.md) +still governs explicit tab closure. Intent: [desktop windows](../../../../specs/desktop-windows.md). +Regression cases in [the owning suite](../../../../packages/components/tests/session-tab-close-target.test.ts) +cover parent, child, draft, focused/hidden side panel, multiple tabs, and empty surfaces. + +## Verification limits + +Oxfmt passes for the three changed TypeScript files. The checkout has no installed +dependencies: `pnpm check`, `pnpm format`, and targeted Vitest are blocked by missing +tsgo, oxfmt, and vitest respectively. Documentation checks retain existing submodule +link failures. Native Electron interaction has not been exercised in this checkout. diff --git a/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md new file mode 100644 index 000000000..661171394 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md @@ -0,0 +1,30 @@ +# 最后一个对话标签的快捷键关闭窗口 + +Status: implemented +Translation: current + +[English](2026-09-21-last-tab-window-close.md) | 中文 + +## 摘要 + +新开的会话窗口只有一个对话标签时,Cmd/Ctrl+W 原先关闭标签并留下草稿,而没有关闭窗口。 +关闭目标解析现在在仅剩一个对话标签时交给原生窗口关闭,子会话和草稿同样适用。 +有焦点的侧面板标签仍优先关闭,显式点击标签 × 保持既有生命周期行为。 +主窗口和辅助窗口采用同一规则。 + +## 决策与证据 + +解析函数原先接收却忽略可见对话数量。现在返回明确的窗口目标,SessionDetail 将其交给 +已有的窗口关闭回退路径,不执行共享标签状态变更。无需按窗口来源添加分支,也保留了 +主窗口隐藏和辅助窗口关闭的原生区别。多个对话标签仍逐个关闭。 + +此次只修改快捷键路由;[空标签删除决策](2026-09-18-empty-tab-close-exact-delete.zh.md) +仍约束显式标签关闭。产品意图见[桌面多窗口](../../../../specs/desktop-windows.zh.md)。 +[既有测试套件](../../../../packages/components/tests/session-tab-close-target.test.ts) +覆盖父会话、子会话、草稿、聚焦及隐藏的侧面板、多标签和空页面。 + +## 验证限制 + +改动的三个 TypeScript 文件通过 Oxfmt 格式检查。当前检出没有安装依赖, +`pnpm check`、`pnpm format` 和定向 Vitest 分别被缺少 tsgo、oxfmt、vitest 阻断。 +文档检查仍有既有子模块断链。本次未在原生 Electron 窗口中执行交互验证。 diff --git a/packages/components/src/components/sessions/AGENTS.md b/packages/components/src/components/sessions/AGENTS.md index e73be2a35..ba4ee52b7 100644 --- a/packages/components/src/components/sessions/AGENTS.md +++ b/packages/components/src/components/sessions/AGENTS.md @@ -1,9 +1,8 @@ # components/sessions -`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. +`CLAUDE.md` symlinks here. Edit `AGENTS.md` only. -Parent AGENTS apply. Files: [README.md](README.md); data: `context/message-flow.md`. -Read each heading’s linked context before changing its files. +Parent rules apply. Read each heading’s linked context before edits. ## [Tabs and `?tab` routing](../../../../../.agents/docs/sessions-tabs-routing.md) @@ -17,6 +16,7 @@ Read each heading’s linked context before changing its files. may replace the current choice with a neighbour or local draft. Close writes `isTabClosed`, never archive — a never-messaged tab is exact-deleted. Reopening archives restores lifecycle first. +- Cmd/Ctrl+W: lone conversation closes window; focused side-panel tab closes first. - `Change owner` writes the OWNER `SessionMeta.userId`, never sharing/visibility; they stay separate actions. diff --git a/packages/components/src/components/sessions/README.md b/packages/components/src/components/sessions/README.md index 6cc9c5d9d..016d7e250 100644 --- a/packages/components/src/components/sessions/README.md +++ b/packages/components/src/components/sessions/README.md @@ -20,7 +20,7 @@ describes the shared parsing and persistence boundary. | `session-tab-bar.tsx` | Desktop merged top row: session tab pills, status slot, drag sources | | `adaptive-tab-strip.tsx` | Width sharing for the tab pills | | `session-side-panel-tab-bar.tsx` | Right-panel tab strip (fixed panels, side chats, viewers) | -| `session-tab-close-target.ts` | Registration for the Cmd/Ctrl+W close target | +| `session-tab-close-target.ts` | Resolve the focused tab or last-tab window close target | | `session-list-rows.ts` | Sidebar/tab row derivation, including child grouping by parent | | `child-tab-empty-state.tsx` | Suggestions shown in an empty child tab | | `session-not-found.tsx` | Missing-session surface | diff --git a/packages/components/src/components/sessions/session-detail.tsx b/packages/components/src/components/sessions/session-detail.tsx index 38ff29ef3..bd421e912 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -4442,7 +4442,7 @@ const SessionDetail = ({ () => { const target = resolveFocusedTabCloseTarget(); if (!target) return 'handled'; - if (target.kind === 'landing') { + if (target.kind === 'window') { return 'unhandled'; } if (target.kind === 'side-panel') { diff --git a/packages/components/src/components/sessions/session-tab-close-target.ts b/packages/components/src/components/sessions/session-tab-close-target.ts index 2187343c4..a2f951783 100644 --- a/packages/components/src/components/sessions/session-tab-close-target.ts +++ b/packages/components/src/components/sessions/session-tab-close-target.ts @@ -5,13 +5,14 @@ export type SessionTabFocusRegion = 'conversation' | 'side-panel'; export type SessionTabCloseTarget = | { kind: 'conversation'; tabId: string } | { kind: 'side-panel'; tabId: string } - | { kind: 'landing' }; + | { kind: 'window' }; export function getSessionTabCloseTarget({ focusRegion, sidePanelOpen, activeSidePanelTabId, activeConversationTabId, + conversationTabCount, }: { focusRegion: SessionTabFocusRegion; sidePanelOpen: boolean; @@ -22,13 +23,14 @@ export function getSessionTabCloseTarget({ }): SessionTabCloseTarget | null { if (focusRegion === 'side-panel' && sidePanelOpen) { if (activeSidePanelTabId) return { kind: 'side-panel', tabId: activeSidePanelTabId }; - return activeConversationTabId === EMPTY_SESSION_TAB_ID ? { kind: 'landing' } : null; + return activeConversationTabId === EMPTY_SESSION_TAB_ID ? { kind: 'window' } : null; } if (activeConversationTabId !== EMPTY_SESSION_TAB_ID) { + if (conversationTabCount === 1) return { kind: 'window' }; return { kind: 'conversation', tabId: activeConversationTabId }; } if (sidePanelOpen && activeSidePanelTabId) { return { kind: 'side-panel', tabId: activeSidePanelTabId }; } - return { kind: 'landing' }; + return { kind: 'window' }; } diff --git a/packages/components/tests/session-tab-close-target.test.ts b/packages/components/tests/session-tab-close-target.test.ts index ffd1034ef..e4f57175b 100644 --- a/packages/components/tests/session-tab-close-target.test.ts +++ b/packages/components/tests/session-tab-close-target.test.ts @@ -56,15 +56,40 @@ describe('getSessionTabCloseTarget', () => { ).toEqual({ kind: 'conversation', tabId: 'parent-session' }); }); - it('closes the last parent tab before closing the window', () => { + it.each(['parent-session', 'child-session', 'draft:local'])( + 'closes the window for the last conversation tab (%s)', + (activeConversationTabId) => { + expect( + getSessionTabCloseTarget({ + ...BASE_INPUT, + activeConversationTabId, + conversationTabCount: 1, + }) + ).toEqual({ kind: 'window' }); + } + ); + + it('keeps side-panel close priority with only one conversation tab', () => { expect( getSessionTabCloseTarget({ ...BASE_INPUT, - activeConversationTabId: 'parent-session', + focusRegion: 'side-panel', conversationTabCount: 1, }) - ).toEqual({ kind: 'conversation', tabId: 'parent-session' }); + ).toEqual({ kind: 'side-panel', tabId: 'changes' }); }); + + it('closes the window when the last-focused panel is hidden and one conversation remains', () => { + expect( + getSessionTabCloseTarget({ + ...BASE_INPUT, + focusRegion: 'side-panel', + sidePanelOpen: false, + conversationTabCount: 1, + }) + ).toEqual({ kind: 'window' }); + }); + it('yields the window close accelerator on the empty surface', () => { expect( getSessionTabCloseTarget({ @@ -73,7 +98,7 @@ describe('getSessionTabCloseTarget', () => { sidePanelOpen: false, conversationTabCount: 0, }) - ).toEqual({ kind: 'landing' }); + ).toEqual({ kind: 'window' }); }); it('closes an available side-panel tab before yielding window close on the empty surface', () => { expect( diff --git a/specs/desktop-windows.md b/specs/desktop-windows.md new file mode 100644 index 000000000..b76761d6c --- /dev/null +++ b/specs/desktop-windows.md @@ -0,0 +1,57 @@ +# Desktop multi-window + +Status: draft +Translation: current + +English | [中文](desktop-windows.zh.md) + +Users can view conversations and workspaces side by side; opening a window leaves +its source window unchanged. + +| Action | Result | +| --- | --- | +| Ordinary Session click or workspace switch | Navigate in the current window | +| Command-click on macOS, Control-click on other desktops | Open the target in a new window | +| Session more/context menu: Open conversation in new window | Open a conversation window | + +Dragging out to create windows is unsupported; Session dragging retains mention +insertion. The workspace selector shows the modifier-click hint at its bottom. +Its context menu offers Open in new window without switching the current workspace. +Session menus order actions as expand/collapse children, pin, mark unread, rename; +copy link, copy branch, share; open PR, go to source Session, open in new window; +archive. Separate groups without headings or empty groups, hide unavailable +actions, and keep Open in new window penultimate and Archive last in ordinary text. + +Conversation windows initially collapse the left sidebar, open the requested +conversation, and focus its composer once. The sidebar can be expanded. +Workspace windows retain full navigation. Navigation, draft tabs, and panel state +are independent per window. + +Composer drafts persist per workspace, isolated from other workspaces, regardless +of which window opened them. All Chat Landing entries in a workspace reuse its +composer draft. New conversation changes only the target project, preserving input; +clear the draft only after the first message is successfully written to a Session. +Closing another workspace window does not stop the CLI or Agent. Close menus and +shortcuts act on the current window. + +Cmd/Ctrl+W in the conversation region closes the active tab when multiple tabs +remain. With one tab, including a draft, it closes the current window without +changing shared Session closure state. A focused side panel closes its active tab +first. Explicit tab × still closes the tab. Primary and new conversation windows +follow the same rule, preserving each window's native close/hide behavior. +Application shortcut settings are device-wide and changes propagate to all open +windows; execution acts only in the window receiving the key. The original window +retains existing close/tray behavior; other workspace windows are not automatically +restored. + +Hiding the sidebar unmounts its content and pauses Session prefetch; data required +by the current conversation and Tasks page continues syncing. Only one window per +workspace runs notifications, badges, and background status checks, handing off +on exit. Cache clearing and logout are application-wide and close other windows +to release connections. + +Workspace windows use the existing platform directory; the public desktop still +has one local workspace. Shared UI multi-workspace entries must not change other +windows' targets through global account selection. + +Requirement: [Issue #526](https://github.com/LodyAI/Lody/issues/526). diff --git a/specs/desktop-windows.zh.md b/specs/desktop-windows.zh.md index 2530557fb..e6a478b60 100644 --- a/specs/desktop-windows.zh.md +++ b/specs/desktop-windows.zh.md @@ -1,7 +1,9 @@ # 桌面多窗口 Status: draft -Translation: pending +Translation: current + +[English](desktop-windows.md) | 中文 用户可并排查看对话和工作区,打开新窗口不改变来源窗口。 @@ -27,6 +29,9 @@ Composer 草稿归属工作区并持久化:不同工作区必须隔离,工 同一工作区内的所有 Chat Landing 入口复用同一份 Composer 草稿;“新建对话”只切换目标项目, 不得清空或覆盖已有输入。只有首条消息成功写入 Session 后才清空这份草稿。 关闭其他工作区窗口不停止 CLI 或 Agent;关闭菜单和快捷键操作当前窗口。 +Cmd/Ctrl+W 在对话区有多个标签时关闭当前标签,只剩一个标签(包括草稿)时关闭当前窗口, +不更改会话的共享关闭状态。侧面板有焦点时优先关闭其当前标签;显式点击标签 × 仍关闭标签。 +主窗口和新开的会话窗口遵循相同规则,保留各窗口已有的原生关闭/隐藏行为。 应用内快捷键配置按设备共享,任一窗口修改后对所有已打开窗口生效;快捷键执行仍只作用于接收按键的窗口。 原始窗口保留已有的关闭/托盘行为;不自动恢复其他工作区窗口。 From 4b1e7fb000c00387af03d8b28a389320d795dee6 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:55:51 +0800 Subject: [PATCH 2/2] docs: link last-tab window close decision to PR Model: gpt-6 --- .../implemented/bug-fix/2026-09-21-last-tab-window-close.md | 1 + .../implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md index 156af0eba..70bf604d8 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md +++ b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.md @@ -2,6 +2,7 @@ Status: implemented Translation: current +PR: [#866](https://github.com/LodyAI/Lody/pull/866) English | [中文](2026-09-21-last-tab-window-close.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md index 661171394..62c6bb486 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-21-last-tab-window-close.zh.md @@ -2,6 +2,7 @@ Status: implemented Translation: current +PR: [#866](https://github.com/LodyAI/Lody/pull/866) [English](2026-09-21-last-tab-window-close.md) | 中文