diff --git a/AGENTS.md b/AGENTS.md index 4ceb9bf..df30631 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -604,7 +604,7 @@ interface OpenTabSeed { 任意 tab(内置或插件注册的)可拖出侧边栏,成为悬浮在主会话区域上的**自由窗口**(free window): -- **拖出**:把标签栏的 tab 拖到**主会话区域**(conversation 列)上——虚线提示浮层标记落区,松开即浮动(`floatTab` reducer:tab 从 pane **移动**进 `SidebarState.floats`,窗口以默认尺寸 480×360 居中于松点、钳入视口;清空的 pane 折叠)。检测在 `src/client/Sidebar.tsx`:document 捕获 `dragover`/`drop`,以 `body[data-dsh-tab-dragging]`(TabBar 维护)门控,目标矩形取 `#root [data-slot="conversation"]` 的父列;窄视口(合并抽屉)禁用手势。tab 右键菜单的**「移动到自由窗口」**(两面板共用 TabBar,一处生效)始终可用,窗口落在会话列中心。 +- **拖出**:把标签栏的 tab 拖到**主会话区域**(conversation 列)上——虚线提示浮层标记落区,松开即浮动(`floatTab` reducer:tab 从 pane **移动**进 `SidebarState.floats`,窗口以默认尺寸 390×780(手机竖屏比例,创建时先按视口减 24px 钳高)居中于松点、钳入视口;清空的 pane 折叠)。检测在 `src/client/Sidebar.tsx`:document 捕获 `dragover`/`drop`,以 `body[data-dsh-tab-dragging]`(TabBar 维护)门控,目标矩形取 `#root [data-slot="conversation"]` 的父列;窄视口(合并抽屉)禁用手势。tab 右键菜单的**「移动到自由窗口」**(两面板共用 TabBar,一处生效)始终可用,窗口落在会话列中心。 - **窗口操作**(`src/client/FreeWindow.tsx`,渲染在 `[data-dsh-panel-host]` 内、z-index 42):头部 pointer 拖动移动(rAF 直写 DOM + 松手提交 store 的面板拖拽模式);拖动中指针落在 pane(`[data-dsh-pane]`)上时该 pane 高亮(`data-dsh-float-dock-over`),松开即**停靠**(center 合并进该 pane,`dockFloat`);右下角缩放(`FLOAT_MIN_W/H` = 320/200,SE 角锚定);点击任意处置顶(层叠顺序 = `floats` 数组序,`raiseFloat`);头部右键菜单「回到侧边栏 / 关闭」;X = `closeTab`(关闭 tab 与窗口,正常触发 `onClose` 生命周期、释放终端)。 - **持久化**:`floats` 随会话进 localStorage(`dsh-sidebar:v1:`),刷新原样恢复;`sanitizeState` 对每条 float **宽容校验**(非法条目单独丢弃不动整体布局,几何钳入当前视口,diff/ephemeral tab 不持久化,旧文档缺字段 = `[]`);`maxCounterId` 的 uid 播种覆盖 `float:N` 前缀。 - **服务语义**(消费插件可见,`features` 含 `'floatWindows'`):`openTab` 的 dedupeKey/id 聚焦命中**浮动中的 tab = 置顶其窗口**(不重复开、不展开面板);`closeTab`/`activateTab` 对浮动 tab 分别关窗/置顶并照常触发 `onClose`/`onActivate`;agent 终端 reconcile 会随宿主列表移除已消失的浮动终端窗口。tab 内容复用常规 `renderTab`(`TabContent`),浮窗对 `visible` 恒为 true,所有 tab 类型无差别浮动。 diff --git a/docs/plans/2026-08-23-free-window-design.md b/docs/plans/2026-08-23-free-window-design.md index 0704137..598e9d5 100644 --- a/docs/plans/2026-08-23-free-window-design.md +++ b/docs/plans/2026-08-23-free-window-design.md @@ -23,7 +23,7 @@ | 手势/入口 | 行为 | |---|---| -| 按住 tab 拖到主会话区域 | 会话列出现虚线提示浮层「松开以在自由窗口中打开」;松开 → tab **移动**(pane 中移除)为浮窗,默认 480×360 居中于松点、钳入视口 | +| 按住 tab 拖到主会话区域 | 会话列出现虚线提示浮层「松开以在自由窗口中打开」;松开 → tab **移动**(pane 中移除)为浮窗,默认 390×780(手机竖屏比例;创建时按视口钳制)居中于松点、钳入视口 | | tab 右键菜单「移动到自由窗口」 | 同上,落点为会话列中心(右面板与底面板的 TabBar 共用组件,一处生效) | | 浮窗头部按住拖动 | 移动窗口(pointer capture + rAF 直写 DOM + 松手提交 store);拖到侧边栏 pane 上方时该 pane 高亮,松开即停靠(center 合并) | | 浮窗头部右键 | 菜单:「回到侧边栏」(停靠到激活 pane)/「关闭」 | diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 4b90cc6..3419e9a 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -876,7 +876,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { overflow-y: auto; /* Deep trees truncate their labels instead of scrolling horizontally. */ overflow-x: hidden; - padding: 2px 6px 8px; + padding: 4px 8px 8px; } /* One tree row: the app's session-row cell (34px, radius 8, inset, hover @@ -1196,7 +1196,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { display: flex; align-items: center; gap: 6px; - padding: 4px 8px; + padding: 6px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); } @@ -1215,6 +1215,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { .editorPathInput { flex: 1; min-width: 0; + box-sizing: border-box; height: 28px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1); @@ -1297,7 +1298,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { flex: none; display: flex; align-items: center; - gap: 4px; + gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); } @@ -1430,7 +1431,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { .editorBanner { flex: none; - padding: 4px 12px; + padding: 4px 8px; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-state-warn-label); background: var(--dsw-alias-state-warn-tertiary); @@ -1633,6 +1634,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel { height: 100%; } +/* Code lines get the same 12/16 inset as the markdown preview, so toggling + preview/edit never jumps the text's resting position. */ +.editorCm :global(.cm-scroller) { + padding: 12px 16px; +} + .editorCm :global(.cm-editor.cm-focused) { outline: none; } @@ -1688,7 +1695,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { flex: 1; min-height: 0; overflow-y: auto; - padding: 10px 14px; + padding: 12px 16px; font: var(--dsw-font-xs-13); } diff --git a/src/client/state.ts b/src/client/state.ts index 2dc3792..fef1b62 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -120,9 +120,12 @@ export const BOTTOM_DEFAULT = 220 * header plus some content), the ceiling is the viewport. */ export const FLOAT_MIN_W = 320 export const FLOAT_MIN_H = 200 -/** Geometry a fresh free window starts with (clamped to the viewport). */ -export const FLOAT_DEFAULT_W = 480 -export const FLOAT_DEFAULT_H = 360 +/** Geometry a fresh free window starts with: a phone-like portrait ratio + * (390×780 ≈ 1:2). The creation path additionally caps the size to the + * viewport (minus a 24px margin), so a short viewport gets a shorter — + * not overflowing — window instead of an exact ratio. */ +export const FLOAT_DEFAULT_W = 390 +export const FLOAT_DEFAULT_H = 780 let nextIdCounter = 0 /** Unique pane/tab id within one state instance. */ @@ -777,11 +780,17 @@ export function floatTab(state: SidebarState, tabId: string, x: number, y: numbe if (leaf.tabs.length === 0) emptied = true }) if (emptied) node = removeLeafAt(node, source.id) + // Phone-ratio default, capped to the viewport before centering so the + // clamped position never leaves the window's bottom past the fold. + const vw = viewportW() + const vh = viewportH() + const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24)) + const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24)) const window = clampFloatGeometry( - x - FLOAT_DEFAULT_W / 2, - y - FLOAT_DEFAULT_H / 2, - FLOAT_DEFAULT_W, - FLOAT_DEFAULT_H, + x - width / 2, + y - height / 2, + width, + height, ) const next: SidebarState = { ...state, diff --git a/tests/e2e/float-window.e2e.ts b/tests/e2e/float-window.e2e.ts index 52c5bb7..e2e819b 100644 --- a/tests/e2e/float-window.e2e.ts +++ b/tests/e2e/float-window.e2e.ts @@ -174,7 +174,7 @@ test('float a tab, move the window, reload restores it, dock it back', async ({ const leftBefore = await floatWindow.evaluate((node) => Number.parseFloat((node as HTMLElement).style.left)) expect(Number.isFinite(leftBefore), `style.left must be numeric, got ${leftBefore}`).toBe(true) expect(leftBefore).toBeGreaterThan(0) - expect(leftBefore).toBeLessThan(1440 - 480) + expect(leftBefore).toBeLessThan(1440 - 390) await assertNoCrash(page) // 2. The header drag moves the window (pointer capture + commit). diff --git a/tests/free-window.spec.tsx b/tests/free-window.spec.tsx index d3fd343..c90d498 100644 --- a/tests/free-window.spec.tsx +++ b/tests/free-window.spec.tsx @@ -213,9 +213,10 @@ describe('free windows: the window', () => { const state = store.getSnapshot().state! expect(state.floats).toHaveLength(1) expect(state.floats[0]!.tab.id).toBe('notes') - // Born at the conversation column's center, minus half the default - // size (480x360), clamped into the 1024x768 viewport. - expect(state.floats[0]).toMatchObject({ x: 400 - 240, y: 325 - 180, w: 480, h: 360 }) + // Born at the conversation column's center with the phone-ratio + // default (390x780), created capped to the jsdom viewport (h 744) and + // clamped in (the centered y would be negative). + expect(state.floats[0]).toMatchObject({ x: 400 - 195, y: 0, w: 390, h: 744 }) expect(container.querySelector('[data-dsh-float-window]')).not.toBeNull() } finally { unmount() @@ -230,20 +231,22 @@ describe('free windows: the window', () => { act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) const win = container.querySelector('[data-dsh-float-window]')! const header = win.querySelector('[class*="floatHeader"]')! - expect(win.style.left).toBe('272px') + expect(win.style.left).toBe('317px') // Grab at (300, 220) — the drag records the window's own origin, so - // moving the pointer to (200, 250) lands the window at (172, 234). + // moving the pointer to (200, 250) lands the window at (217, 42) — + // clamped back up to y 24 (the 744-tall window must stay in the + // 768-tall viewport: 768 - 744 = 24). act(() => { header.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 220, button: 0 })) header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 })) }) await flushFrame() - expect(win.style.left).toBe('172px') - expect(win.style.top).toBe('234px') + expect(win.style.left).toBe('217px') + expect(win.style.top).toBe('24px') // The store is untouched mid-drag (DOM is the only record). - expect(store.getSnapshot().state!.floats[0]!.x).toBe(272) + expect(store.getSnapshot().state!.floats[0]!.x).toBe(317) act(() => { header.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200, clientY: 250, button: 0 })) }) - expect(store.getSnapshot().state!.floats[0]).toMatchObject({ x: 172, y: 234 }) + expect(store.getSnapshot().state!.floats[0]).toMatchObject({ x: 217, y: 24 }) } finally { unmount() } @@ -258,14 +261,16 @@ describe('free windows: the window', () => { const win = container.querySelector('[data-dsh-float-window]')! const handle = win.querySelector('[class*="floatResize"]')! act(() => { - handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 752, clientY: 564, button: 0 })) - handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 852, clientY: 614 })) + handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 700, clientY: 750, button: 0 })) + handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 800, clientY: 800 })) }) await flushFrame() - expect(win.style.width).toBe('580px') - expect(win.style.height).toBe('410px') - act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 852, clientY: 614, button: 0 })) }) - expect(store.getSnapshot().state!.floats[0]).toMatchObject({ w: 580, h: 410 }) + // +100/+50 from the created 390x744: height clamps to the viewport + // ceiling (768 - y 12 = 756). + expect(win.style.width).toBe('490px') + expect(win.style.height).toBe('756px') + act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 800, clientY: 800, button: 0 })) }) + expect(store.getSnapshot().state!.floats[0]).toMatchObject({ w: 490, h: 756 }) } finally { unmount() } @@ -279,7 +284,7 @@ describe('free windows: the window', () => { act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) const win = container.querySelector('[data-dsh-float-window]')! const header = win.querySelector('[class*="floatHeader"]')! - expect(win.style.left).toBe('272px') + expect(win.style.left).toBe('317px') act(() => { header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 220 })) }) // The menu list is PORTALED to document.body, yet its events still // bubble through the React tree into the header's onPointerDown — @@ -293,7 +298,7 @@ describe('free windows: the window', () => { header.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200, clientY: 250 })) }) await flushFrame() - expect(win.style.left).toBe('272px') + expect(win.style.left).toBe('317px') act(() => { dockRow.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(store.getSnapshot().state!.floats).toHaveLength(0) expect(paneTabs(store)).toContain('notes') diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 482558c..d542b12 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -919,8 +919,9 @@ describe('free windows (v0.16.0)', () => { expect(leaf.active).toBe('term:1') expect(floated.floats).toHaveLength(1) expect(floated.floats[0]!.tab).toEqual(git) - // Default size centered on the drop point (no viewport → floors only). - expect(floated.floats[0]).toMatchObject({ x: 300 - 240, y: 200 - 180, w: 480, h: 360 }) + // Phone-ratio default (390x780) centered on the drop point (no viewport + // → size cap never bites; only the negative y clamps to 0). + expect(floated.floats[0]).toMatchObject({ x: 300 - 195, y: 0, w: 390, h: 780 }) expect(floated.activePane).toBe(leaf.id) }) @@ -961,8 +962,9 @@ describe('free windows (v0.16.0)', () => { expect(moved.floats[0]).toMatchObject({ x: 0, y: 0 }) // Beyond the right/bottom edge clamps the top-left so the window fits. moved = moveFloat(moved, id, 5000, 5000) - expect(moved.floats[0]!.x).toBe(1024 - 480) - expect(moved.floats[0]!.y).toBe(768 - 360) + // The float was CREATED capped to the viewport: w 390, h 768-24=744. + expect(moved.floats[0]!.x).toBe(1024 - 390) + expect(moved.floats[0]!.y).toBe(768 - 744) // No-op move returns the same reference (no persist churn). expect(moveFloat(moved, id, moved.floats[0]!.x, moved.floats[0]!.y)).toBe(moved) // Resize: floors, viewport ceiling, and the SE-corner anchor (x/y keep).