Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/docs/sessions-tabs-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Close the window from the last conversation tab

Status: implemented
Translation: current
PR: [#866](https://github.com/LodyAI/Lody/pull/866)

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 最后一个对话标签的快捷键关闭窗口

Status: implemented
Translation: current
PR: [#866](https://github.com/LodyAI/Lody/pull/866)

[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 窗口中执行交互验证。
6 changes: 3 additions & 3 deletions packages/components/src/components/sessions/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/components/sessions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' };
}
33 changes: 29 additions & 4 deletions packages/components/tests/session-tab-close-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
Expand Down
57 changes: 57 additions & 0 deletions specs/desktop-windows.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 6 additions & 1 deletion specs/desktop-windows.zh.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# 桌面多窗口

Status: draft
Translation: pending
Translation: current

[English](desktop-windows.md) | 中文

用户可并排查看对话和工作区,打开新窗口不改变来源窗口。

Expand All @@ -27,6 +29,9 @@ Composer 草稿归属工作区并持久化:不同工作区必须隔离,工
同一工作区内的所有 Chat Landing 入口复用同一份 Composer 草稿;“新建对话”只切换目标项目,
不得清空或覆盖已有输入。只有首条消息成功写入 Session 后才清空这份草稿。
关闭其他工作区窗口不停止 CLI 或 Agent;关闭菜单和快捷键操作当前窗口。
Cmd/Ctrl+W 在对话区有多个标签时关闭当前标签,只剩一个标签(包括草稿)时关闭当前窗口,
不更改会话的共享关闭状态。侧面板有焦点时优先关闭其当前标签;显式点击标签 × 仍关闭标签。
主窗口和新开的会话窗口遵循相同规则,保留各窗口已有的原生关闭/隐藏行为。
应用内快捷键配置按设备共享,任一窗口修改后对所有已打开窗口生效;快捷键执行仍只作用于接收按键的窗口。
原始窗口保留已有的关闭/托盘行为;不自动恢复其他工作区窗口。

Expand Down
Loading