feat(ui): 文件二级页面间距规整(8px 网格)+ 浮窗默认尺寸改手机竖屏比例

- 文件页 chrome 行统一:路径行与树搜索行同为 41px 高(路径输入框改
  border-box 28px 总高)、gap 统一 6px、内边距统一 6px 8px
- 树体侧距 6→8px 对齐 chrome 行;markdown 预览 10/14→12/16(与错误
  文案块一致);CodeMirror scroller 新增同款 12/16 内边距(预览/编辑
  切换文字位置不跳);截断横幅 12→8px
- 浮窗默认尺寸 480×360 → 390×780(1:2 手机竖屏比例),创建时先按
  视口减 24px 钳制再居中,矮视口得到更矮的窗而非溢出
- 测试/文档同步:state/free-window 预期重算(含 clampMove 回推的
  y=24 语义)、e2e 断言宽度上界、AGENTS §7.5 与设计文档
This commit is contained in:
Menghuan1918
2026-08-24 00:59:27 +08:00
parent f648b0c006
commit d15645bf23
7 changed files with 59 additions and 36 deletions
+1 -1
View File
@@ -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:<sessionId>`),刷新原样恢复;`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 类型无差别浮动。
+1 -1
View File
@@ -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)/「关闭」 |
+12 -5
View File
@@ -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);
}
+16 -7
View File
@@ -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,
+1 -1
View File
@@ -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).
+22 -17
View File
@@ -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<HTMLElement>('[data-dsh-float-window]')!
const header = win.querySelector<HTMLElement>('[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<HTMLElement>('[data-dsh-float-window]')!
const handle = win.querySelector<HTMLElement>('[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<HTMLElement>('[data-dsh-float-window]')!
const header = win.querySelector<HTMLElement>('[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')
+6 -4
View File
@@ -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).