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:
@@ -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 类型无差别浮动。
|
||||
|
||||
@@ -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)/「关闭」 |
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user