diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml new file mode 100644 index 0000000000..f6a8425e96 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md +2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba +2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md new file mode 100644 index 0000000000..c89c5328ee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md @@ -0,0 +1,25 @@ +# Agent Note: Keep the room reading independent of hidden split controls + +Status: implemented + +English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md) + +## Problem + +The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in. + +## Decision + +When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before. + +## Alternatives considered + +**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split. + +**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize. + +**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading. + +## Consequences + +The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch. diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md new file mode 100644 index 0000000000..edf9948e54 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Keep the room reading independent of hidden split controls + +Status: implemented + +[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文 + +## Problem + +dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。 + +## Decision + +当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。 + +## Alternatives considered + +**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。 + +**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。 + +**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。 + +## Consequences + +空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index f9094ecb23..6c2a754dc0 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e -2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710 +2026-09-04-right-sidebar-docking-infrastructure.md: 3f6bcff565598c4d8a24e374d308c388c1f6bb0a +2026-09-04-right-sidebar-docking-infrastructure.zh.md: aea7aacc4cb50b8974ada4a7e7eec27ed41623b9 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 3600a17fdd..3f6bcff565 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -37,9 +37,9 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes; ### State -[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes. +[Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes. -`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. +`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the current default page is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. ### Beyond the surface @@ -69,7 +69,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind **Undo and redo buttons on the panel header.** Shipped first, then removed: the sequence is an architectural fact, and stepping it is not a product action yet. The API stays reachable as `@internal` methods for tests and the future navigation controller. -**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent now settles the surface so an emptied pane is merged away and an emptied root pane reseeds the guide. +**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied pane is merged away and an emptied root pane reseeds the current default page. **Inline the kit through `packages/util` and the `INLINE_SAFE` list.** A build probe showed it works, but the util build chain has no CSS pipeline and the kit ships a stylesheet; the static-linked client package (the `ui-primitives` precedent) was chosen knowing that changing the kit means rebuilding the shell and reloading. @@ -77,7 +77,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split. - Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls. -- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane brings the guide back. +- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane restores the current default page. - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide. - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement. - The Sidebar panel never moves when the presentation switches, and its slide is the same in both presentations; the conversation is the only thing that animates on a switch. A hidden panel keeps its tabs mounted, so a preview survives a collapse. diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index 2a9ddfb7ec..aea7aacc4c 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -37,9 +37,9 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 ### 状态 -[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。 +[默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。 -`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 +`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上当前默认页——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 ### 面之外 @@ -69,7 +69,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 **面板头部的 undo 与 redo 按钮。** 先上后撤:序列是架构事实,步进它现在还不是产品动作。API 以 `@internal` 方法保留给测试与将来的导航控制器。 -**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;现在每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上引导。 +**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上当前默认页。 **经 `packages/util` 与 `INLINE_SAFE` 清单内联库。** 构建探针证明可行,但 util 构建链没有 CSS 管线而库带样式表;在知晓改库须重建壳并刷新页面的前提下,选择静态链接的 client 包(`ui-primitives` 先例)。 @@ -77,7 +77,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。 - 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。 -- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会让引导回来。 +- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会恢复当前默认页。 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。 - 切换呈现模式时 Sidebar 面板一动不动,两种模式的平移一模一样;切换时只有会话区在动。隐藏的面板保持 tab 挂载,预览在折叠后仍在。 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml index 2d8d26b371..e6392aa970 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md -2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4 -2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36 +2026-09-08-sidebar-default-pages.md: 13374ab294cab74b54ada8550a6dff5af2cd9cd1 +2026-09-08-sidebar-default-pages.zh.md: 6730a87fbe9c59ef6d448fe0f2d4c3c2cb75e6bb diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md index c78d6a3af2..13374ab294 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md @@ -1,4 +1,4 @@ -# Agent Note: Sidebar default pages and close protection +# Agent Note: Sidebar default pages Status: implemented @@ -6,13 +6,13 @@ English | [中文](2026-09-08-sidebar-default-pages.zh.md) ## Problem -A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again. +A guide with one registered entry adds a click without offering a choice. ## Decision The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one. -A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only. +The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) owns close protection: the sole docked guide remains open, while any other sole tab closes together with the column. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only. This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active. @@ -20,8 +20,6 @@ This replaces default-guide selection in [the shipped types](2026-09-05-sidebar- **Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry. -**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior. - ## Consequences -One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide. +One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab. Store and component tests cover registration counts; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing a lone tab. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md index 8e0a081cb2..6730a87fbe 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Sidebar 默认页与关闭保护 +# Agent Note: Sidebar 默认页 Status: implemented @@ -6,13 +6,13 @@ Status: implemented ## 问题 -只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。 +只有一个注册入口的引导页增加一次点击,却不提供选择。 ## 决策 Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。 -单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。 +[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责关闭保护:作为唯一停靠 tab 的引导页保持打开,其他任何唯一 tab 都会连同整列一起关闭。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。 本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。 @@ -20,8 +20,6 @@ Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入 **统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。 -**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。 - ## 后果 -单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。 +单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab。store 与组件测试覆盖注册数量;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭唯一 tab 后返回 Files。 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml new file mode 100644 index 0000000000..0f2da39358 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md +2026-09-08-sidebar-last-tab-close-rules.md: 4fb0e54af1075dd595d18db4d74779a7cfd2656b +2026-09-08-sidebar-last-tab-close-rules.zh.md: 87cadf0f15eecb0cadc2fb2dacc5695771fd66f7 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md new file mode 100644 index 0000000000..4fb0e54af1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md @@ -0,0 +1,27 @@ +# Agent Note: Last-tab close rules on the Sidebar's docked surface + +Status: implemented + +English | [中文](2026-09-08-sidebar-last-tab-close-rules.zh.md) + +## Problem + +The settle planner guarantees the docked surface is never empty: closing the last tab reseeds the current default page. That guarantee made the last tab's close control a dead end in both directions. Closing the guide standing alone put the same guide straight back — a control that does nothing. Closing any other lone tab left the user with a column showing only the default page — after "close the last thing", an expanded panel with nothing in it is not what the gesture meant. The guide's chip also drew a hover capsule and a context menu whose only item was that no-op close. + +## Decision + +The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and lets the settle planner prepare the current default page for the next expansion. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active. + +Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all produces no visible popup, so a secondary press on such a chip shows nothing rather than an empty box. + +## Alternatives considered + +**Keep the guide closable and let settle reseed it.** The visible result is a close control that does nothing; the control lies about what a press will do. + +**Hide the close in the Sidebar's renderer instead of a kit prop.** The kit draws the chip's close and the menu's close item, so the embedder cannot withhold them without a seam; a CSS override would leave the menu item live and split one decision across two owners. + +**Collapse the column from the kit when the last tab closes.** The kit has no concept of the column or its expansion; the collapse is the embedder's intent, recorded by the store alongside the close in the same entry. + +## Consequences + +`canCloseTab` is a third control-policy prop every embedder may set; leaving it out keeps every tab closable. The quiet-chip and empty-menu rules are unconditional kit behavior keyed on the same policy, so any embedder withholding a lone tab's close gets the same presentation. Reopening the column after a lone-tab close shows the default page selected from the current guide entries. Kit specs cover the withheld control, the quiet chip, and the self-dismissing menu; Sidebar unit specs cover `closeTab`'s refusal and the close-with-column entry; a [browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) walks the whole rule on the rendered panel. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md new file mode 100644 index 0000000000..87cadf0f15 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Sidebar 停靠面最后一个 tab 的关闭规则 + +Status: implemented + +[English](2026-09-08-sidebar-last-tab-close-rules.md) | 中文 + +## 问题 + +settle planner 保证停靠面永不为空:关掉最后一个 tab 会重新播种当前默认页。这条保证让最后一个 tab 的关闭控件在两个方向上都走进死胡同。独自留下的引导页被关闭后,同一个引导页立刻回来——一个什么也不做的控件。任何其它 tab 独自留下时被关闭,用户面前只剩一列只显示默认页的面板——在「关掉最后一个东西」之后,一块展开着却空无内容的面板不是这个手势的本意。引导页的 chip 还画着悬停胶囊,右键菜单里唯一的条目就是那个无效的关闭。 + +## 决定 + +停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,于是 settle planner 为下次展开准备当前默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。 + +两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单不会产生可见弹层,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。 + +## 考虑过的替代方案 + +**让引导页保持可关闭,由 settle 重新播种。** 可见的结果是一个什么也不做的关闭控件;这个控件在按下会发生什么这件事上撒谎。 + +**在 Sidebar 的渲染器里藏掉关闭,而不加套件 prop。** chip 的关闭控件与菜单的关闭项都由套件绘制,没有接缝嵌入方就无法收起它们;CSS 覆盖会留下仍然生效的菜单项,把一个决定拆给两个所有者。 + +**由套件在最后一个 tab 关闭时收起整列。** 套件没有「列」或「展开」的概念;收起是嵌入方的意图,由 store 在同一条历史里与关闭一并记录。 + +## 后果 + +`canCloseTab` 成为每个嵌入方都可设置的第三个控制策略 prop;不设置时每个 tab 都可关闭。安静 chip 与空菜单两条规则是套件的无条件行为,键在同一策略上,任何收起了独 tab 关闭的嵌入方都得到同样的呈现。独 tab 关闭后重新展开的列显示根据当前引导入口选出的默认页。套件 spec 覆盖收起的控件、安静 chip 与自行消失的菜单;Sidebar 单元 spec 覆盖 `closeTab` 的拒绝与「关闭连带整列」的历史条目;一个[浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts)在渲染出的面板上走完整条规则。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index b153e29dff..8f1bf2033a 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -353,7 +353,7 @@ describe('web e2e: agent-preset selection', () => { expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"')) + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index a8189f65de..58b9913ce6 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -130,9 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () expect(await column.locator('[data-dockkit-tab]').count()).toBe(1) const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText() const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() - expect(initialFilesClose).toBe(0) + expect(initialFilesClose).toBe(1) await filesTab.click({ button: 'right' }) - expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(0) + expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1) + await page.keyboard.press('Escape') await addTab.waitFor({ state: 'visible' }) const initialAdd = await addTab.count() expect(initialAdd).toBe(1) @@ -143,9 +144,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count() const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count() expect(guideClose).toBe(1) - expect(filesCloseWithGuide).toBe(0) + expect(filesCloseWithGuide).toBe(1) await expect.poll(() => addTab.count()).toBe(0) const addWithGuide = await addTab.count() + await guideTab.hover() await guideTab.locator('[data-dockkit-tab-close]').click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0) @@ -153,7 +155,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await addTab.waitFor({ state: 'visible' }) const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() const restoredAdd = await addTab.count() - expect(restoredFilesClose).toBe(0) + expect(restoredFilesClose).toBe(1) expect(restoredAdd).toBe(1) const preview = column.locator('[data-document-preview]') const openFile = async (name: string): Promise => { diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 2a59c43dde..e81602eb6a 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -6,8 +6,7 @@ - img - img - text: Minimal mode -- button "Session log": - - text: Session log +- button "More actions": - img -- button "Open the sidebar": +- button "Open right sidebar": - img diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 1bc1752380..72096d4001 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Clickable links gallery" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 92887b13f2..2ae1fa24fa 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -14,10 +14,9 @@ - button "Review deepseek-harness/deepseek-harness#314" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index aa35d67e04..b8b2c1f554 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -14,10 +14,9 @@ - button "Review deepseek-harness/deepseek-harness#314" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index d5fddccca9..a9d28a21e7 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -3,10 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 8cccd6e2a7..e09d2dc04f 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "CJK strong emphasis" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index c010943b60..8d8f01047a 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Markdown image policy" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index e042455176..6a9dfa0298 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Inline code links" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 20e51e6c63..0f4c7db9f8 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Math rendering" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 7ea2707435..5ca8cc92cc 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Reference order target" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index f14b04d9a9..e2821f6fbb 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -3,10 +3,9 @@ - button "/user-invoke-demo and confirm the fixtur" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index e0901950d2..7e65f42144 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -3,10 +3,9 @@ - button "/user-invoke-demo and confirm the fixtur" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 6a3f9ecbb6..d76b371ba1 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "{{workspace}}" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 11e83471a3..5fcde028a1 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index b1644d07d5..c4dbd84fb6 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index acbaf1b148..6dbe3e41c2 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 297257c678..d51e08bb49 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -274,12 +274,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) await ensureSeedOpen(page) - const exportButton = page.getByRole('button', { name: 'Session log' }) + const exportButton = page.getByRole('button', { name: 'More actions' }) expect(await exportButton.isDisabled()).toBe(false) const header = exportButton.locator('xpath=ancestor::header[1]') // The right Sidebar's expand button holds the header's corner; the export // control sits immediately to its left. - const sidebarButton = page.getByRole('button', { name: 'Open the sidebar' }) + const sidebarButton = page.getByRole('button', { name: 'Open right sidebar' }) const [buttonBox, sidebarBox, headerBox] = await Promise.all([ exportButton.boundingBox(), sidebarButton.boundingBox(), header.boundingBox(), ]) @@ -293,6 +293,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) await exportButton.click() + await page.getByRole('menuitem', { name: 'Download session log' }).click() const response = await responsePromise expect(response.status()).toBe(200) const download = await downloadPromise diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 09110c5360..84096583f9 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -433,7 +433,7 @@ describe('web e2e: seeded history renders through cold resume', () => { // Put the column back so the later goldens see the default frame. await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') - await page.getByRole('button', { name: 'Open the sidebar', exact: true }).waitFor({ state: 'visible' }) + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' }) await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' }) }) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 257e0c9f6e..c6a7ec07cd 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -20,7 +20,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Locator, Page } from 'playwright' +import type { Browser, ConsoleMessage, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' @@ -360,14 +360,13 @@ describe('web e2e: shipped right Sidebar', () => { expect(panelWidth).toBeGreaterThan(0) expect(await width(conversation)).toBe(centerBefore - panelWidth) await expect.poll(async () => await expand.count()).toBe(0) - // The corner keeps its footprint, so the utilities' right edge stays where - // it was relative to the conversation's own right edge. - expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1) + // The corner seat collapses with its button, so the utilities' right edge + // moves out toward the conversation's own. const utilitiesAfter = await utilities.boundingBox() const conversationAfter = await conversation.boundingBox() if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered') const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width) - expect(Math.round(gapAfter)).toBe(Math.round(gapBefore)) + expect(gapAfter).toBeLessThan(gapBefore) // The panel is in the column, not over it, and carries the seeded tab — // whose body arrives through the Files type's keyed registration, not from @@ -391,23 +390,24 @@ describe('web e2e: shipped right Sidebar', () => { expect(await centreY(selector), selector).toBe(textLine) } - // Files is permanent. A manual guide is closable and suppresses another - // add control in its pane until it is closed. + // A manual guide is closable beside Files and suppresses another add + // control in its pane until it is closed. const addTab = column.locator('[data-dockkit-add-tab]') const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) - expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(1) expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine) await addTab.click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(0) - expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1) const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1) // Back to the seeded shape the cases below start from. + await guideTab.hover() await guideTab.locator('[data-dockkit-tab-close]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await expect.poll(async () => await addTab.count()).toBe(1) @@ -592,6 +592,73 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }) + it('survives grip drags past both clamps on a squeezed viewport', async () => { + // Regression: an overshoot drag swept the panel through widths where a + // width-blocked split control hid itself, which changed the very strip + // measurement that had blocked it — a layout-effect feedback loop that + // crashed the pane (React error #185) and unmounted the rightbar slot + // entry while the column still believed it was expanded, so neither the + // panel nor the header's expand button remained. The crash surfaces only + // as a console error, which the scaffold tripwire does not watch, so this + // case collects console errors itself. + const viewport = page.viewportSize() + if (viewport === null) throw new Error('expected a fixed viewport') + const column = await resetSidebar(page) + const frame = page.locator('[class*="frame"]').first() + const panel = column.locator('[data-sidebar-right-panel]') + const consoleErrors: string[] = [] + const collect = (message: ConsoleMessage): void => { + if (message.type() === 'error') consoleErrors.push(message.text().slice(0, 600)) + } + page.on('console', collect) + try { + await page.setViewportSize({ width: 1000, height: viewport.height }) + await ensureExpanded(page, column) + await width(column) + const grip = frame.locator('[data-side="rightbar"]') + // The frame reads the new viewport through a throttled ResizeObserver, + // a couple of frames after the resize; until then the grip sits at the + // old frame's coordinates. Press only a grip aligned with the panel's + // left edge (the handle is 8px wide, centred on the seam). + await expect.poll(async () => { + const gripBox = await grip.boundingBox() + const panelBox = await panel.boundingBox() + if (gripBox === null || panelBox === null) return Number.NaN + return Math.abs(gripBox.x + 4 - panelBox.x) + }).toBeLessThanOrEqual(1) + // Narrow with overshoot: drag the grip far right past the clamp floor. + const from = await centre(grip) + await page.mouse.move(from.x, from.y) + await page.mouse.down() + await page.mouse.move(980, from.y, { steps: 30 }) + await page.mouse.up() + // The panel holds its floor, still open, with its grip still rendered. + await expect.poll(async () => await width(panel)).toBeLessThanOrEqual(302) + expect(await width(panel)).toBeGreaterThanOrEqual(300) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await grip.count()).toBe(1) + // Widen with overshoot to the far left: clamped by the frame's range. + const back = await centre(grip) + await page.mouse.move(back.x, back.y) + await page.mouse.down() + await page.mouse.move(20, back.y, { steps: 30 }) + await page.mouse.up() + const widened = await width(panel) + expect(widened).toBeGreaterThan(302) + expect(widened).toBeLessThan(1000) + expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1) + expect(await grip.count()).toBe(1) + expect(consoleErrors).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + page.off('console', collect) + await page.setViewportSize(viewport) + await ensureExpanded(page, column) + await setPanelWidth(page, Math.round(viewport.width * 0.45)) + } + }, 60_000) + it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => { const files = (scaffold.ctx as unknown as { get(name: string): { @@ -686,7 +753,7 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await panes.count()).toBe(2) const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) - expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1) await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) @@ -850,33 +917,58 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) - it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => { + it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle')) const column = await resetSidebar(page) const panes = column.locator('[data-dockkit-pane]') - expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1) await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME]) await panes.first().locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) - await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }), - await pointIn(panes.nth(1), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME]) - const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) - expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) - await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - // The remaining ordinary document is also unclosable while alone. - expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0) - await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5)) + // Closing a pane's last tab drops the pane: there is no separate + // "close pane" gesture, and none is needed. + await panes.nth(1).locator('[data-dockkit-tab]').first().hover() + await panes.nth(1).locator('[data-dockkit-tab-close]').first().click() await expect.poll(async () => await panes.count()).toBe(1) - const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) - await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1) - await documentTab.locator('[data-dockkit-tab-close]').click() - await expect.poll(async () => await documentTab.count()).toBe(0) - expect(await panes.count()).toBe(1) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) + + // Leave a guide as the sole docked tab. + const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await files.hover() + await files.locator('[data-dockkit-tab-close]').click() + await column.locator('[data-dockkit-add-tab]').click() + await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start']) + const sample = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) + await sample.hover() + await sample.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + + // The guide standing as the docked surface's only tab draws no close + // control, sits quiet (no capsule, no hover fill), and a secondary press + // opens no menu: an empty menu never shows. expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) + expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1) + await column.locator('[data-dockkit-tab]').first().click({ button: 'right' }) + expect(await page.locator('[data-dockkit-tab-menu]').isVisible()).toBe(false) + expect(await page.getByRole('menu').count()).toBe(0) + + // Any other tab standing alone closes together with the column. Open the + // sample file, close the guide (an ordinary close with two tabs), then + // close the file: the column collapses in the same gesture, and the + // settle rule reseeds the current default, so reopening shows Files. + await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + await column.locator('[data-dockkit-tab]').first().hover() + await column.locator('[data-dockkit-tab-close]').first().click() + await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME]) + await column.locator('[data-dockkit-tab]').first().hover() + await column.locator('[data-dockkit-tab-close]').first().click() + await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) + await expandOf(page).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + expect(await column.locator('[data-files-state="tree"]').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -959,8 +1051,8 @@ describe('web e2e: shipped right Sidebar', () => { // as a layout defect that is not there. expect(await width(column)).toBeGreaterThan(300) await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始']) - await expect.poll(async () => await guide.locator('p').first().innerText()) - .toBe('侧栏用来放你想一直看着的东西。') + await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText()) + .toBe('工作区文件') await shot(zhPage, '05-guide-copy-zh') expect(zhTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 1ca602fbd7..da207c2c52 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -3,10 +3,9 @@ - button "Stream one TypeScript fence for" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index cf76e02b1b..795090f465 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -143,10 +143,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** * The header's far-right corner, past the utilities' edge and into the - * header's own padding, for one control that must keep its place whether or - * not it currently shows anything. The corner reserves its width while an - * occupant is registered, so the utilities beside it never move; an - * occupant with nothing to show renders a same-size placeholder. + * header's own padding, for one control. The corner is laid out only while + * its occupant renders something; an occupant with nothing to show renders + * nothing, and the utilities take the header's edge. */ 'conversation.session.header.corner': { kind: 'single' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index f988537220..22420cc8d8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -34,23 +34,17 @@ --dsh-composer-dock-inset: 8px; } +/* 76px with its rule: the height of the Sidebar's tab strip and header row + (ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules + meet at the column edge. The rows add up to it — 10px top inset, the 30px + title row, the tab strip's 10px margin, a 16px tab line, and its 9px bottom + inset — so no row below may grow past its figure. */ .header { - position: relative; flex: none; - padding: 12px 28px 0 20px; - border-bottom: 1px solid transparent; -} - -.header::after { - content: ''; - position: absolute; - right: 0; - bottom: 1px; - left: 0; - z-index: 0; - height: 0.5px; - background: var(--dsw-alias-border-l3); - pointer-events: none; + box-sizing: border-box; + min-height: 76px; + padding: 10px 28px 0 20px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); } /* Blank hero/settling: keep the strict Session header mounted without taking @@ -63,7 +57,7 @@ display: flex; align-items: center; gap: 0; - min-height: 32px; + min-height: 30px; } .titleCluster { @@ -147,14 +141,14 @@ } /* The far-right corner seat reaches 16px into the header's 28px right padding, - so its control sits past the utilities' edge; it is laid out only while an - occupant is registered, and the occupant keeps its width while hidden, so the - utilities never move because of it. */ + so its control sits past the utilities' edge; it is laid out only while its + occupant renders something. Its left margin equals the utilities' 8px gap so + the header's trailing controls space evenly. */ .headerCorner { display: flex; flex: none; align-items: center; - margin-left: 12px; + margin-left: 8px; margin-right: -16px; } @@ -162,20 +156,23 @@ display: none; } -/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ +/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. + Positioned above the header's own paint, so the active bar covers the rule. */ .tabs { position: relative; z-index: 1; display: flex; gap: 36px; - margin-top: 4px; + margin-top: 10px; padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500). The 2px bar + reaches 1px past the tab's box to end flush with the header's bottom edge, + sitting over the rule. */ .tab { position: relative; - padding: 0 0 11px; + padding: 0 0 9px; border: none; background: transparent; font-size: 13px; @@ -189,7 +186,7 @@ content: ''; position: absolute; right: 0; - bottom: 1px; + bottom: -1px; left: 0; height: 2px; border-radius: 2px; @@ -346,6 +343,10 @@ flex: 1; flex-direction: column; min-height: 0; + /* The product-wide 2px scrollbar offset (ui-workspace WorkspaceBrowser + `.list`, ui-sidebar-files FilesBody `.body`): the bar sits 2px clear of + the column's edge instead of flush against it. */ + margin-right: 2px; overflow-y: auto; /* Reserved unconditionally: the composer seat rides this box's content box in Chat and its padding box under a view's composer overlay, so an `auto` @@ -354,6 +355,12 @@ scrollbar-gutter: stable; } +/* The bar also stops 2px short of the scroller's ends, matching its 2px edge + offset. WebKit-only: the Firefox path has no track to inset. */ +.scrollBody::-webkit-scrollbar-track { + margin: 2px; +} + .root[data-phase='active'] .viewArea { flex: 1 0 auto; min-height: auto; diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index f28d39fc71..f2fbcc914d 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md -README.md: f4f76072bd16cd75bff74715f8f9437348ee036a -README.zh.md: 39e20b42bfabb49630b1c9e108b43010d6e7b199 +README.md: 396253d771196380d04da13cfc0843584b37fbc2 +README.zh.md: ec2d5077ef847b3fa50d7e78113b978ec4fa7507 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index f4f76072bd..396253d771 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -37,7 +37,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a - `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it. - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). -**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. The optional `canCloseTab(tabId)` hides the chip, menu, and floating close controls; the embedder enforces closing in its intent handler. A chip is a capsule carrying its close control when allowed; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. ## Embedding it @@ -47,12 +47,12 @@ Everything host-specific arrives through props: | Contract | Carries | |---|---| | `DockLabels` | every rendered string, already localized, accessible names included | -| `TabRenderer` | one tab's body (`renderTab`), and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | +| `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | | `DockIntents` | the settled results of every gesture | -`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Two props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`) and `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane). Hiding the add control moves nothing else in the strip. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. +`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. -`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitAtCapacity` hides the split control at the pane budget; its default is false, and a width-blocked control remains disabled. +`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end. @@ -64,18 +64,16 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp These are not stylistic; each one fixes a defect found in a real browser. - **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works. -- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow: hidden`); the add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. -- **The chip box is not a scroll container.** A horizontal scroller claims press-and-move for itself; tabs shrink, ellipsize, and then clip instead. -- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 44px` on a content-box, so its footprint is 44px plus 10px + 5px of padding, 59px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (4px). A column split, which only an edge drop makes, needs each half to hold the strip (36px) plus a 48px body: one 13px secondary line at 1.6 line-height inside the body's 12px padding. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow`, and offers no edge drop zone for that axis (the release is then not a move). A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. +- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen. +- **The chip box scrolls, but never claims a gesture.** A horizontal scroller would take press-and-move for itself and cancel the pointer; the box, the chips, and the strip set `touch-action: none` and the gesture captures the pointer, so a press-and-move on a chip is a drag and only the wheel scrolls the box. +- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 80px` on a content-box, so its footprint is 80px plus 10px + 10px of padding, 100px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (0 — its hairline paints over the seam without taking layout room, so a body's own rules run unbroken past it). A column split, which only an edge drop makes, needs each half to hold the strip (34px) plus a 48px body: one 13px secondary line at 1.6 line-height inside 12px of the body's own insets — the pane body itself is unpadded, so a tab's body reaches the strip's bottom edge and the pane's edges and draws its own. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow` (hidden instead under `hideSplitWhenBlocked`), and offers no edge drop zone for that axis (the release is then not a move). Under `hideSplitWhenBlocked` the split control's own footprint — its box plus the strip's gap — is left out of the fixed part: hiding the control sheds exactly that footprint from the strip, so a reading that counted it would flip with the control's visibility and re-render forever; leaving it out is also what the half being asked about would carry, since a half too narrow to split hides its own control. A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split. - **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing. - **A control nested inside a draggable chip stops its own press.** Otherwise the press starts a drag, captures the pointer, and the nested control's click never lands. -- **Emphasis takes the platform's accent, never `--dsw-alias-brand-primary`.** This platform binds `brand-primary` to its near-black (light) or near-white (dark) foreground, so a hovered divider, the drop caret, and the drop-zone hint use `--dsw-alias-brand-primary-new-colorprimary-new-color`, as the trajectory views do. A floating panel's border is the same `--dsw-alias-border-l2` hairline whether it is active or not: the active panel is already on top and casts the shadow; a darker frame around it read as a defect. +- **Emphasis takes the platform's accent, never `--dsw-alias-brand-primary`.** This platform binds `brand-primary` to its near-black (light) or near-white (dark) foreground, so the drop caret and the drop-zone hint use `--dsw-alias-brand-primary-new-colorprimary-new-color`, as the trajectory views do; a hovered divider takes the caption label ink instead, reading as a handle rather than a highlight. A floating panel draws no border — the menu's shadow (`--dsw-elevation-prominent`) outlines it — and the active panel gets no heavier frame: it is already on top and casts the same shadow; a darker frame around it read as a defect. ## Build shape -Its static ESM retains third-party imports for the Web shell's Vite build; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). - The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 39e20b42bf..ec2d5077ef 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" - `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 -**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 ## 如何嵌入 @@ -47,12 +47,12 @@ kind: "package-reference" | 契约 | 承载内容 | |---|---| | `DockLabels` | 每一个渲染出来的字符串,已本地化,含无障碍名称 | -| `TabRenderer` | 一个 tab 的正文(`renderTab`),以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | +| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | | `DockIntents` | 每次手势落定的结果 | -`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有两个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)与 `canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)。隐藏添加控件不会移动 tab 条里的其它任何东西。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 +`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 -`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitAtCapacity` 在达到窗格预算时隐藏分栏控件,默认值为 false;宽度不足的控件仍以禁用状态显示。 +`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。 @@ -64,18 +64,16 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。 -- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow: hidden`);添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 -- **chip 盒不是滚动容器。** 横向滚动容器会把按下并移动据为己有;tab 转而收缩、省略、然后被裁切。 -- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 44px`,所以它的足印是 44px 加 10px + 5px 内边距,即 59px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(4px)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(36px)加 48px 正文:正文 12px 内边距内一行 13px、行高 1.6 的次级文字。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用,并且在该轴上不提供边缘落区(松手就不是移动)。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。 +- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。 +- **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。 +- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。 - **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。 - **嵌套在可拖动 chip 里的控件要拦住自己的按下。** 否则按下会开始拖动、捕获指针,嵌套控件的 click 就永远落不下。 -- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此悬停的分隔条、落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致。浮窗的边框无论是否活动都是同一条 `--dsw-alias-border-l2` 细线:活动浮窗本就在最上层并投下阴影;围它一圈更深的边框读起来像缺陷。 +- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致;悬停的分隔条改用 caption 文字色,读起来是把手而不是高亮。浮窗不画边框——菜单同款阴影(`--dsw-elevation-prominent`)已勾出它的轮廓——活动浮窗也不加重边框:它本就在最上层、投同样的阴影;围它一圈更深的边框读起来像缺陷。 ## 构建形态 -静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 - 本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 968d1ba505..d4ce40915b 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -25,6 +25,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 6c074c86ca..6c3e0ced4d 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -33,8 +33,8 @@ export interface DockSurfaceProps { * split control disabled with `labels.splitPaneNarrow` (see README). */ readonly canSplit: boolean - /** Hide the split control when the pane budget is spent; defaults to false. Width-blocked controls remain disabled. */ - readonly hideSplitAtCapacity?: boolean + /** Hide a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; defaults to false. */ + readonly hideSplitWhenBlocked?: boolean /** Body drop geometry: all edge bands, or left/right halves with whole-pane moves once splitting is unavailable. */ readonly dropZones?: 'edges' | 'horizontal' /** Smallest share a divider may leave a pane; defaults to the kit's fraction. */ @@ -45,7 +45,13 @@ export interface DockSurfaceProps { * end controls where they are and the chips as the only shrinking part. */ readonly canAddTab?: (paneId: PaneId) => boolean - /** Whether a tab offers close controls; defaults to true. Called per tab on every render. */ + /** + * Whether a tab draws its close control and its menu's close item. Called + * per rendered chip on every render; omit to keep every tab closable. + * `false` removes both routes without moving the chip: the close control + * paints over the title's end rather than beside it, so the chip is the same + * width either way. The menu still opens and carries the embedder's items. + */ readonly canCloseTab?: (tabId: TabId) => boolean readonly intents: DockIntents readonly labels: DockLabels @@ -97,7 +103,7 @@ const NO_PREVIEW: Preview = { draggingTabId: undefined, dropTarget: undefined, s /** Nothing measured yet: every pane fits until a reading says otherwise. */ const NO_FITS: ReadonlyMap = new Map() -/** The default add-control policy: every pane offers one. */ +/** The default policy for the omitted callbacks: every pane offers the add control, every tab its close. */ const ALWAYS = (): boolean => true /** @@ -157,7 +163,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { /** The split tree and the gestures over it. */ export function DockSurface({ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, - dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false, + dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false, }: DockSurfaceProps): ReactNode { const surface = useRef(null) const [preview, setPreview] = useState(NO_PREVIEW) @@ -178,10 +184,10 @@ export function DockSurface({ // wider or narrower). A reading that changed nothing renders nothing. const remeasure = useCallback((): void => { withSurface((root) => { - const next = measurePaneFits(root) + const next = measurePaneFits(root, hideSplitWhenBlocked) setFits(current => sameFits(current, next) ? current : next) }) - }, [withSurface]) + }, [withSurface, hideSplitWhenBlocked]) useLayoutEffect(() => { remeasure() }) useEffect(() => { onRoom?.(fits) }, [fits, onRoom]) useEffect(() => { @@ -267,7 +273,7 @@ export function DockSurface({ }) }, splitBlock, - hideSplitAtCapacity, + hideSplitWhenBlocked, canAddTab: canAddTab ?? ALWAYS, canCloseTab: canCloseTab ?? ALWAYS, dropTarget: preview.dropTarget, diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index 4247928b21..d929339953 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -1,7 +1,8 @@ /** * The floating layer: one overlay panel per floating pane, bottom-to-top in the - * model's z order. A floating pane hosts exactly one tab and renders no tab - * strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip + * model's z order. A floating pane hosts exactly one tab; its header is the + * strip's row holding that tab's chip, never selectable or closable from the + * chip, and the send-back and close controls. Pressing a panel's body raises it. Its grip * and corner report through their gesture instead: a press released in place is * a click and raises the panel; a drag records the move or resize, and that * operation raises the panel itself, so one gesture is one intent. Raising a @@ -14,12 +15,15 @@ */ import { useState } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' +import clsx from 'clsx' +import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' import { movedRect, resizedRect } from '../engine/geometry.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' import { useGesture } from './pointer.ts' +import { TabTitle } from './TabTitle.tsx' import css from './dockkit.module.css' /** The layout whose `floats` this layer draws. */ @@ -114,32 +118,39 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, onPointerDown={() => { raise(paneId) }} >
{ drag('move', paneId, event) }} > - {renderTabTitle?.(tab) ?? tab.title} - - {(canCloseTab?.(tab.id) ?? true) && ( +
+ {renderTabTitle?.(tab) ?? tab.title} +
+
+ + + {(canCloseTab?.(tab.id) ?? true) && ( + + + )}
{renderTab(tab)}
diff --git a/packages/client/ui-dockkit/src/components/TabMenu.tsx b/packages/client/ui-dockkit/src/components/TabMenu.tsx index 93634d0e12..d3f458c8b4 100644 --- a/packages/client/ui-dockkit/src/components/TabMenu.tsx +++ b/packages/client/ui-dockkit/src/components/TabMenu.tsx @@ -2,8 +2,10 @@ * The per-tab context menu, opened by a secondary press on the chip. It carries * the close gesture and whatever the embedder appends; the copy and float * gestures have no menu item — copying is an embedder API, floating is a drag - * released clear of the surface. Presentational — it renders what its props - * supply and dismisses itself on outside presses. + * released clear of the surface. A menu that would hold no item at all renders + * no popup, so a secondary press on a chip with nothing to offer shows nothing. + * Presentational — it renders what its props supply and dismisses itself on + * outside presses. * * It renders in a portal, positioned against the control that opened it. The tab * strip clips its overflow on purpose (so it never becomes a scroll container @@ -26,12 +28,11 @@ export interface TabMenuProps { readonly labels: DockLabels /** The control that opened the menu; the menu hangs below its left edge. */ readonly anchor: HTMLElement - /** Whether to offer close; custom items remain available when false. */ - readonly canCloseTab: boolean - readonly onClose: () => void + /** Close the tab; `undefined` removes the kit's item, leaving the extras only. */ + readonly onClose: (() => void) | undefined /** Dismiss without acting. */ readonly onDismiss: () => void - /** Embedder ARIA menu items, rendered after the kit's own; absent means none. */ + /** Embedder items, rendered after the kit's own; absent means none. */ readonly extras: ReactNode } @@ -49,18 +50,19 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties { } /** The actions menu body, anchored to the control that opened it. */ -export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode { +export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode { const self = useRef(null) const [position, setPosition] = useState(undefined) - const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '') + const hasItems = onClose !== undefined || Children.toArray(extras).some(item => item !== '') useLayoutEffect(() => { if (self.current === null) return setPosition(placeMenu(anchor, self.current)) - }, [anchor, canCloseTab, hasItems]) + }, [anchor, hasItems]) useEffect(() => { const menu = self.current + /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ if (menu === null) return undefined // A press anywhere but inside the menu dismisses it; one with no element // target (dispatched to the window itself) counts as outside. @@ -91,7 +93,7 @@ export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extra onPointerDown={(event) => { event.stopPropagation() }} onClick={(event) => { event.stopPropagation() }} > - {canCloseTab && ( + {onClose !== undefined && ( diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index 1258002996..47f025dab5 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -3,48 +3,92 @@ * active tab's body with the dock preview overlay. Presentational; every gesture * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`. * - * A chip is a capsule carrying an optional close control at its right end; the - * context menu (secondary press) carries the same close plus whatever the - * embedder appends. The chips sit in their own box, the strip's one shrinking - * part: in a narrow pane they ellipsize and then clip there, so the add - * control after them (drawn while the embedder's `canAddTab` allows), the - * pane's split control, and the embedder's chrome keep their width and their - * place at the strip's end. + * A chip is a capsule carrying one control, its close, shown over its right + * end while the chip is active, hovered, or focused; the context menu + * (secondary press) carries the same close plus whatever the embedder appends. + * Both close routes draw only while the embedder's `canCloseTab` allows, and + * a pane's lone chip whose close is withheld draws quiet — no capsule, no + * hover fill — since there is nothing to select against and nothing to do to + * it. + * Between neighbouring chips sits a slot: a fixed-width box drawing a + * hairline, blank beside the active chip, and the drop caret when a drag + * targets that index, so a caret never widens the row; the two end slots + * exist only while targeted. The chips sit in their own box, the strip's one + * shrinking part: in a narrow pane their titles fade at the clipped edge down + * to the chip's floor and then the chips scroll there, keeping the active one + * in view, so the add control after them (drawn while the embedder's + * `canAddTab` allows), the pane's split control, and the embedder's chrome keep + * their width and their place at the strip's end. */ -import { Fragment, useState } from 'react' -import type { ReactNode } from 'react' +import { Fragment, useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' -import type { LayoutState, PaneNode, TabId } from '../contract/types.ts' +import { IconCloseFill14, IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DockZone, LayoutState, PaneNode, TabId } from '../contract/types.ts' import { getTab } from '../engine/tree.ts' import type { PaneCallbacks, SplitBlock } from './render.ts' import { TabMenu } from './TabMenu.tsx' +import { TabTitle } from './TabTitle.tsx' import css from './dockkit.module.css' -/** The split control's glyph: a frame divided by a vertical line, as the split itself is. */ +/** + * The ic_ds_panel_left_outline_16 frame alone: its outer and inner rounded + * rectangles as one even-odd ring, without the divider. The glyphs below draw + * inside it so they read as siblings of the panel controls beside them. + */ +const PANEL_FRAME = 'M9.67272 0.522841C10.8339 0.522841 11.76 0.522714 12.4963 0.602493C13.2453 0.683657 13.8789 0.854248 14.4264 1.25197C14.7504 1.48739 15.0355 1.77247 15.2709 2.0965C15.6686 2.64394 15.8392 3.27758 15.9204 4.02655C16.0002 4.7629 16 5.68895 16 6.85014V9.14986C16 10.3111 16.0002 11.2371 15.9204 11.9735C15.8392 12.7224 15.6686 13.3561 15.2709 13.9035C15.0355 14.2275 14.7504 14.5126 14.4264 14.748C13.8789 15.1458 13.2453 15.3163 12.4963 15.3975C11.76 15.4773 10.8339 15.4772 9.67272 15.4772H6.3273C5.16611 15.4772 4.24006 15.4773 3.50371 15.3975C2.75474 15.3163 2.1211 15.1458 1.57366 14.748C1.24963 14.5126 0.964549 14.2275 0.729131 13.9035C0.331407 13.3561 0.160817 12.7224 0.0796529 11.9735C-0.000126137 11.2371 1.25338e-09 10.3111 1.25338e-09 9.14986V6.85014C1.25329e-09 5.68895 -0.000126137 4.7629 0.0796529 4.02655C0.160817 3.27758 0.331407 2.64394 0.729131 2.0965C0.964549 1.77247 1.24963 1.48739 1.57366 1.25197C2.1211 0.854248 2.75474 0.683657 3.50371 0.602493C4.24006 0.522714 5.16611 0.522841 6.3273 0.522841H9.67272ZM4.1828 14.0873L5.54303 14.1118C5.78636 14.1128 6.04709 14.1169 6.3273 14.1169H9.67272C10.8639 14.1169 11.7032 14.1164 12.3493 14.0465C12.9824 13.9779 13.3497 13.8494 13.6268 13.6482C13.8354 13.4966 14.0195 13.3125 14.1711 13.1039C14.3723 12.8268 14.5007 12.4595 14.5693 11.8264C14.6393 11.1803 14.6398 10.341 14.6398 9.14986V6.85014C14.6398 5.65896 14.6393 4.81967 14.5693 4.1736C14.5007 3.54048 14.3723 3.17318 14.1711 2.89609C14.0195 2.68747 13.8354 2.50337 13.6268 2.35179C13.3497 2.1506 12.9824 2.02212 12.3493 1.95353C11.7032 1.88358 10.8639 1.88307 9.67272 1.88307H6.3273C6.04709 1.88307 5.78636 1.8862 5.54303 1.88715L4.1828 1.91166C3.99125 1.9216 3.8148 1.93577 3.65076 1.95353C3.01764 2.02212 2.65034 2.1506 2.37325 2.35179C2.16463 2.50337 1.98052 2.68747 1.82895 2.89609C1.62776 3.17318 1.49928 3.54048 1.43069 4.1736C1.36074 4.81967 1.36023 5.65896 1.36023 6.85014V9.14986C1.36023 10.341 1.36074 11.1803 1.43069 11.8264C1.49928 12.4595 1.62776 12.8268 1.82895 13.1039C1.98052 13.3125 2.16463 13.4966 2.37325 13.6482C2.65034 13.8494 3.01764 13.9779 3.65076 14.0465C3.81478 14.0642 3.99127 14.0774 4.1828 14.0873Z' + +/** The split control's glyph: the panel frame with its divider moved to the centre. */ function SplitGlyph(): ReactNode { return ( -
+
+ + {labels.dropZone[zone]} +
+
) } @@ -78,10 +122,86 @@ function selects(key: string): boolean { return key === 'Enter' || key === ' ' } -/** The split control's title: what it does, or why it cannot right now. */ -function splitTitle(labels: PaneCallbacks['labels'], block: SplitBlock | undefined): string { +/** + * Which sides of the chip box hold chips scrolled out of view, as the + * `data-dockkit-strip-scroll` value the stylesheet fades: `undefined` while + * every chip is in view. + */ +function hiddenSides(box: HTMLElement): 'start' | 'end' | 'start end' | undefined { + // Sub-pixel scroll positions: a side counts as hidden past one whole pixel. + const start = box.scrollLeft > 1 + const end = box.scrollLeft + box.clientWidth < box.scrollWidth - 1 + if (start && end) return 'start end' + if (start) return 'start' + if (end) return 'end' + return undefined +} + +/** + * Keep the chip box's `data-dockkit-strip-scroll` current: read after each + * commit that can change the chips, on scroll, and on resize. Written to the + * DOM directly rather than through state because a reading never changes + * what renders, only how the stylesheet fades it. + * + * Known gap: a content-width change that alters neither `tabs` nor the box's + * outer size — a live `renderTabTitle` growing a chip, or a drop-caret slot + * mounting mid-drag — keeps the fade at its last reading until the next + * scroll or resize. The fade is orientation chrome, so a stale edge fades a + * few frames late rather than hiding anything. + */ +function useStripScrollFades(box: RefObject, tabs: readonly TabId[]): void { + useLayoutEffect(() => { + const element = box.current + /* v8 ignore next -- the box is rendered unconditionally with the strip. */ + if (element === null) return undefined + const apply = (): void => { + const sides = hiddenSides(element) + if (sides === undefined) delete element.dataset.dockkitStripScroll + else element.dataset.dockkitStripScroll = sides + } + apply() + element.addEventListener('scroll', apply, { passive: true }) + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply) + observer?.observe(element) + return () => { + element.removeEventListener('scroll', apply) + observer?.disconnect() + } + }, [box, tabs]) +} + +/** + * Bring the active chip into the chip box's view whenever the active tab or + * the row of chips changes: a tab opened or selected past the box's edge, or + * moved there by a close or a reorder, scrolls the box to it, with the fade + * band (24px) cleared so the chip is not under it. A chip already in view + * moves nothing. Direct DOM, like the fades above: the box's scroll position + * renders nothing. + */ +function useActiveChipInView( + box: RefObject, + chips: ReadonlyMap, + tabs: readonly TabId[], + activeTabId: TabId | undefined, +): void { + useLayoutEffect(() => { + const element = box.current + const chip = activeTabId === undefined ? undefined : chips.get(activeTabId) + /* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */ + if (element === null || chip === undefined) return + const bounds = element.getBoundingClientRect() + const rect = chip.getBoundingClientRect() + if (rect.left < bounds.left) element.scrollLeft += rect.left - bounds.left - STRIP_FADE + else if (rect.right > bounds.right) element.scrollLeft += rect.right - bounds.right + STRIP_FADE + }, [box, chips, tabs, activeTabId]) +} + +/** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */ +const STRIP_FADE = 24 + +/** Why the split control cannot act right now. */ +function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): string { switch (block) { - case undefined: return labels.splitPane case 'budget': return labels.splitPaneDisabled case 'width': return labels.splitPaneNarrow } @@ -94,6 +214,9 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined) // The mounted chips by tab, for the keys that move focus between them. const [chips] = useState(() => new Map()) + const stripTabs = useRef(null) + useStripScrollFades(stripTabs, pane.tabs) + useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId) const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId) const block = callbacks.splitBlock(pane.id) const target = callbacks.dropTarget @@ -132,14 +255,22 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { }} >
-
+
{pane.tabs.map((tabId, index) => { const tab = getTab(state, tabId) const selected = tabId === pane.activeTabId - const canClose = callbacks.canCloseTab(tabId) + const closable = callbacks.canCloseTab(tabId) + // A pane's lone unclosable chip is a label, not a choice: there is + // no other tab to select against and nothing to do to it. + const quiet = !closable && pane.tabs.length === 1 return ( - {stripIndex === index &&
} + {(index > 0 || stripIndex === index) && ( +
+ )}
{ if (element === null) chips.delete(tabId) else chips.set(tabId, element) @@ -188,8 +321,8 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) }} > - {callbacks.renderTabTitle?.(tab) ?? tab.title} - {canClose && ( + {callbacks.renderTabTitle?.(tab) ?? tab.title} + {closable && ( )} {menu?.tabId === tabId && ( { setMenu(undefined); callbacks.onCloseTab(tabId) }} + onClose={closable ? () => { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined} onDismiss={() => { setMenu(undefined) }} extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })} /> @@ -220,40 +352,45 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { ) })} - {stripIndex === pane.tabs.length &&
} + {stripIndex === pane.tabs.length &&
}
{callbacks.canAddTab(pane.id) && ( - + + + )}
- {!(callbacks.hideSplitAtCapacity && block === 'budget') && ( - + {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( + + + )} {/* The embedder's surface-wide controls, in the top-right pane only: the strip is the surface's top edge, and this pane's end is its corner. */} @@ -273,12 +410,17 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { {active === undefined ?

{callbacks.labels.emptyPane}

: callbacks.renderTab(active)} - {zone !== undefined && (callbacks.horizontalDrops && zone !== 'center' - ? <> -
-
+ {zone !== undefined && ( + <> +
+ {callbacks.horizontalDrops && zone !== 'center' + ? <> + + + + : } - :
)} + )}
) diff --git a/packages/client/ui-dockkit/src/components/TabTitle.tsx b/packages/client/ui-dockkit/src/components/TabTitle.tsx new file mode 100644 index 0000000000..679a27da02 --- /dev/null +++ b/packages/client/ui-dockkit/src/components/TabTitle.tsx @@ -0,0 +1,37 @@ +/** + * A chip's title: one line, clipped at the chip's inset, never ellipsized. + * While the text is wider than its box the span carries + * `data-dockkit-tab-clipped`, and the stylesheet fades the text out at the + * clipped edge in place of an ellipsis. Written to the DOM directly rather + * than through state: a reading changes nothing that renders, only how the + * stylesheet paints it. Re-read after every commit (the text may have + * changed) and whenever the span's box resizes (the chip shrank or grew). + */ +import { useLayoutEffect, useRef } from 'react' +import type { ReactNode } from 'react' +import css from './dockkit.module.css' + +/** Set or clear the span's `data-dockkit-tab-clipped` from its current geometry. */ +function markClipped(element: HTMLElement): void { + // Sub-pixel widths: the text counts as clipped past one whole pixel. + if (element.scrollWidth > element.clientWidth + 1) element.dataset.dockkitTabClipped = '' + else delete element.dataset.dockkitTabClipped +} + +/** The title span of a strip chip or a floating panel's header chip. */ +export function TabTitle({ children }: { readonly children: ReactNode }): ReactNode { + const span = useRef(null) + useLayoutEffect(() => { + /* v8 ignore next -- the span is rendered unconditionally. */ + if (span.current !== null) markClipped(span.current) + }) + useLayoutEffect(() => { + const element = span.current + /* v8 ignore next -- the span is rendered unconditionally. */ + if (element === null || typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver(() => { markClipped(element) }) + observer.observe(element) + return () => { observer.disconnect() } + }, []) + return {children} +} diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 58907277e8..972ec3b951 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -7,10 +7,11 @@ * Colours come from the embedder's token layer; the kit names no literal. Type * follows the embedder's content axis (`--dsh-content-font-size` and its * secondary step) so a surface reads at the same size as the page around it. - * Emphasis — a hovered divider, the drop caret, the drop-zone hint — takes the - * platform's accent (`--dsw-alias-brand-primary-new-colorprimary-new-color`), - * not `--dsw-alias-brand-primary`, which this platform binds to its - * near-black (light) or near-white (dark) foreground. + * Emphasis — the drop caret, the drop-zone hint — takes the platform's accent + * (`--dsw-alias-brand-primary-new-colorprimary-new-color`), not + * `--dsw-alias-brand-primary`, which this platform binds to its near-black + * (light) or near-white (dark) foreground. A hovered divider takes the + * caption label ink instead, reading as a handle rather than a highlight. */ .split { @@ -35,25 +36,87 @@ min-height: 0; } +/* The divider owns no layout room (`SPLIT_MINIMUMS.divider` mirrors the 0): + the halves abut, so a rule a pane draws across its own edge — a header's + hairline — runs unbroken past the seam. The visible rule is a 0.5px hairline + centred on the seam, matching the embedder's other borders, and `::after` + widens the pointer target to 8px by reaching 4px over each neighbour. + `z-index` keeps that overhang above the later sibling, which would otherwise + take the hit. */ .divider { position: relative; + z-index: 1; flex: none; - background: var(--dsw-alias-border-l1); touch-action: none; } +.divider::before, +.divider::after { + content: ''; + position: absolute; +} + +.divider::before { + background: var(--dsw-alias-border-l4); +} + .splitRow > .divider { - width: 4px; + width: 0; cursor: col-resize; } +.splitRow > .divider::before { + top: 0; + bottom: 0; + left: -0.25px; + width: 0.5px; +} + +.splitRow > .divider::after { + top: 0; + bottom: 0; + left: -4px; + right: -4px; +} + .splitColumn > .divider { - height: 4px; + height: 0; cursor: row-resize; } -.divider:hover { - background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +.splitColumn > .divider::before { + left: 0; + right: 0; + top: -0.25px; + height: 0.5px; +} + +.splitColumn > .divider::after { + left: 0; + right: 0; + top: -4px; + bottom: -4px; +} + +/* Hover: a 1px grip fades in over the hairline, its caption ink deepest at + its middle and fading toward both ends. The grip is painted on the `::after` + hit target, centred on the seam, and crossfaded through opacity, because a + gradient cannot transition from the hairline's solid colour. */ +.divider::after { + opacity: 0; + transition: opacity 120ms ease-out; +} + +.divider:hover::after { + opacity: 1; +} + +.splitRow > .divider::after { + background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat; +} + +.splitColumn > .divider::after { + background: linear-gradient(to right, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 100% 1px no-repeat; } /* Both floors: a flex item's minimum is its content's, and a body's longest @@ -74,17 +137,18 @@ min-width: 0; min-height: 0; overflow: hidden; - border: 1px solid transparent; } -.pane[data-dockkit-pane-active] { - border-color: var(--dsw-alias-border-l2); -} - -/* One centre line for everything in the strip: every child — chip, add - control, split control, the embedder's chrome — is 24px tall, and the strip - centres them, so chip text and control glyphs never sit at different heights. - A child with another height would break that; keep them at 24px. +/* A 38px row whose 28px content band sits at its bottom: chips fill the band, + so they end flush with the strip's bottom edge, and the 28px add control, the + 28px split control, and the embedder's 28px chrome centre on the band's one + centre line, so chip text and control glyphs never sit at different heights. + The 10px above is the strip's own top margin, inside its box so the hit + area stays one element; the 10px at the start is the first chip's inset + from the edge, and the 6px at the end puts the last control's glyph 12px + from the edge. The strip draws no border: a body + that wants a rule under it draws its own. A floating panel's header is + this same row. The strip never clips: the chip box below is its one shrinking part, and every control after it is `flex: none`, so a narrow pane costs chips, never @@ -94,27 +158,47 @@ flex: none; gap: 4px; align-items: center; - height: 36px; - padding: 0 6px; - border-bottom: 0.5px solid var(--dsw-alias-border-l1); + height: 28px; + padding: 10px 6px 0 10px; touch-action: none; } -/* The chips. Shrinks to nothing before any control after it moves; what no - longer fits is clipped here. Deliberately not a scroller: a horizontal - scroll container claims a press-and-move as its own gesture and cancels the - pointer, which would abandon every tab drag in a narrow pane. Tabs shrink - and ellipsize first. */ +/* The chips. Shrinks to nothing before any control after it moves; chips keep + their 80px floor, so what no longer fits scrolls here on the wheel, with no + scrollbar drawn. `touch-action: none` keeps a touch press-and-move a tab drag + rather than a pan the scroller would claim and cancel the pointer for. + `data-dockkit-strip-scroll` names the hidden sides (`start`, `end`, or + both); each hidden side fades the chips out over 24px into whatever ground + the pane draws, so the row reads as continuing under the controls. */ .stripTabs { display: flex; flex: 0 1 auto; - gap: 4px; align-items: center; min-width: 0; - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; + /* The scroll to a newly active chip glides rather than jumps. */ + scroll-behavior: smooth; + scrollbar-width: none; touch-action: none; } +.stripTabs::-webkit-scrollbar { + display: none; +} + +.stripTabs[data-dockkit-strip-scroll='end'] { + mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); +} + +.stripTabs[data-dockkit-strip-scroll='start'] { + mask-image: linear-gradient(to right, transparent, black 24px); +} + +.stripTabs[data-dockkit-strip-scroll='start end'] { + mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent); +} + /* Takes the free space and gives it all back first: a zero basis shrinks nothing, so shortage lands on the chip box alone. */ .stripFill { @@ -122,37 +206,64 @@ min-width: 0; } -/* Embedder controls at the strip's end, set off from the kit's own split - control by a hairline so the two groups read as two groups. */ +/* Embedder controls at the strip's end, spaced from the kit's own split + control as they are from each other: the strip's 4px gap plus this 4px + margin equals the 8px between the controls. */ .stripChrome { display: flex; flex: none; - gap: 2px; + gap: 8px; align-items: center; - height: 24px; - margin-left: 2px; - padding-left: 4px; - border-left: 0.5px solid var(--dsw-alias-border-l1); + height: 28px; + margin-left: 4px; } -.caret { +/* The slot between two chips: a 10px box with a hairline down its centre. + Targeted by a drag, the same box draws the caret instead, so the chips + around it never move. */ +.slot { + display: flex; flex: none; - align-self: center; + align-items: center; + justify-content: center; + width: 10px; + height: 28px; +} + +.slot::before { + content: ''; + width: 0.5px; + height: 14px; + background: var(--dsw-alias-border-l4); +} + +/* The active chip is a filled capsule and needs no rule against it: the two + slots beside it go blank, so the row reads as the capsule between bare + chips. A caret in either slot still draws. */ +.tabActive + .slot:not(.slotCaret)::before, +.slot:not(.slotCaret):has(+ .tabActive)::before { + background: transparent; +} + +.slotCaret::before { width: 2px; height: 20px; background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); } +/* The chip: a 28px capsule whose title runs to the right inset. The close + control is not in the flow — it sits over the title's last 14px and shows + while the chip is active, hovered, or holds focus — so a chip is the same + width with and without it and nothing shifts on hover. */ .tab { position: relative; display: flex; flex: 0 1 auto; - gap: 4px; align-items: center; - min-width: 44px; + min-width: 80px; max-width: 170px; - height: 24px; - padding: 0 5px 0 10px; + height: 28px; + padding: 0 10px; color: var(--dsw-alias-label-secondary); font-size: var(--dsh-content-font-size-secondary, 13px); line-height: 1; @@ -163,35 +274,82 @@ user-select: none; } +/* A flex row with a 5px gap, so an embedder's leading glyph (`renderTabTitle`) + centres on the text's line rather than sitting on its baseline and keeps + one distance from it. The clip is horizontal in intent; the line box must + hold the descenders it would otherwise cut, hence a line-height above the + chip's own 1. No ellipsis: a title wider than its box (`TabTitle` sets + `data-dockkit-tab-clipped`) fades out over its last 16px instead. A mask on + the title rather than a painted gradient: the chip's fill is bare, + translucent hover, or the active tag colour, and a mask matches every one + without naming it. */ .tabTitle { + display: flex; + flex: 1 1 auto; + gap: 5px; + align-items: center; + min-width: 0; overflow: hidden; - text-overflow: ellipsis; + line-height: 1.4; } +.tabTitle[data-dockkit-tab-clipped] { + mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); +} + +/* While the close shows, the title is gone under the close's circle and fades + out over the 16px before it: the circle spans the chip's last 24px and the + title ends 10px inside the chip, so the title's last 14px are under it. + Later than the clipped mask, so a clipped chip showing its close fades + under the close. A floating panel's header chip has no close, so it keeps + the clipped mask alone. */ +.tab:not(.floatTitle):hover .tabTitle, +.tab:not(.floatTitle):focus-within .tabTitle, +.tabActive .tabTitle { + mask-image: linear-gradient(to right, black calc(100% - 30px), transparent calc(100% - 14px)); +} + +/* A 20px icon button inset 4px from the chip's end, holding a 14px glyph in + the tertiary ink, so it sits back from the title beside it. */ .tabClose { + position: absolute; + top: 4px; + right: 4px; display: flex; - flex: none; align-items: center; justify-content: center; - width: 16px; - height: 16px; + width: 20px; + height: 20px; padding: 0; - color: inherit; + color: var(--dsw-alias-label-tertiary); line-height: 1; background: transparent; border: none; - border-radius: 50%; - corner-shape: round; + border-radius: 20px; cursor: pointer; + opacity: 0; + /* Hidden means untouchable too: without this a touch press on the chip's + trailing 20px would close the tab instead of activating it. */ + pointer-events: none; } +/* Shown while the chip is hovered or holds focus, and always on the active + chip: the tab in view is the one a reader closes next. */ +.tab:hover .tabClose, +.tab:focus-within .tabClose, +.tabActive .tabClose { + opacity: 1; + pointer-events: auto; +} + +/* The chip's height and corner, so it reads as one more capsule in the row. */ .addTab { display: flex; flex: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; padding: 0; color: var(--dsw-alias-label-secondary); line-height: 1; @@ -208,46 +366,72 @@ .tabClose:hover { color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover-solid); + background: var(--dsw-alias-interactive-bg-hover); } .tab:hover { background: var(--dsw-alias-interactive-bg-hover); } -/* The active chip is the filled capsule; the rest are bare text. */ +/* The active chip is the filled capsule, in the tag fill; the rest are bare text. */ .tabActive { color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-markdown-tag); +} + +/* A pane's lone unclosable chip is a label, not a choice: no capsule, no + hover fill — the primary ink on the pane's own ground. Its title never + yields to a close control, so it keeps the clipped fade alone. */ +.tab.tabQuiet, +.tab.tabQuiet:hover { + color: var(--dsw-alias-label-primary); + background: transparent; + cursor: default; +} + +.tab.tabQuiet .tabTitle, +.tab.tabQuiet:hover .tabTitle, +.tab.tabQuiet:focus-within .tabTitle { + mask-image: none; +} + +.tab.tabQuiet .tabTitle[data-dockkit-tab-clipped] { + mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); } .tabDragging { opacity: 0.5; } +/* The geometry of a message's action row button (ui-chat MessageIconActions): + a 28px circle around a 15px glyph. */ .iconButton { - display: flex; + display: inline-flex; flex: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; - padding: 0; + width: 28px; + height: 28px; + padding: 6px; color: var(--dsw-alias-label-secondary); line-height: 1; background: transparent; border: none; - border-radius: 4px; + border-radius: 28px; cursor: pointer; } +.iconButton svg { + width: 15px; + height: 15px; +} + .iconButton:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } .iconButton:disabled { - color: var(--dsw-alias-label-tertiary); + opacity: 0.4; cursor: default; } @@ -288,12 +472,14 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* No padding of its own: a tab's body reaches the pane's edges and the strip's + bottom edge, and keeps its own insets, so a header row it draws sits flush + under the strip. */ .paneBody { position: relative; flex: 1 1 auto; min-width: 0; min-height: 0; - padding: 12px; overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller inside it rebinds the thumb indirection in a complete pair — a base-surface thumb on an elevated ground reads as a smudge. */ @@ -303,14 +489,40 @@ .empty { margin: 0; + padding: 12px; color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); } +/* The drop hint is a scrim and, over it, one box pair per landing region. + The scrim (`.dockScrim`) covers the whole body with the translucent, blurred + ground, so the insets between and around the cards dim the body's content + instead of letting it show through raw. The outer box (`.dockHint`) is the + region a release fills — the whole body, or 40% (edge bands) or 50% + (horizontal halves) of it — and draws nothing; it is a padded frame that + keeps the card inside it 8px clear of the body's edges and, between two + horizontal halves, 4px clear of the seam, so neighbouring cards sit 8px + apart like the body's own insets. The card (`.dockHintCard`) is what the eye + lands on: a dashed frame with the zone's glyph and caption stacked at its + centre. A card that is not under the pointer stays as a quiet outline so the + reader sees where the other release would land. */ +.dockScrim { + position: absolute; + inset: 0; + pointer-events: none; + /* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2` + is a lighter bluish step than the column's `bg-base`, which tinted the + whole scrim away from the content beneath it. */ + background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, transparent); + backdrop-filter: blur(6px); + animation: dockScrimIn 140ms ease-out; +} + .dockHint { position: absolute; - background: var(--dsw-alias-bg-multi-select); - border: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color); + display: flex; + box-sizing: border-box; + padding: 8px; pointer-events: none; } @@ -332,15 +544,82 @@ width: 40%; } -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'], -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] { +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'] { width: 50%; + padding-right: 4px; } -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left']:not([data-dockkit-drop-active]), -[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right']:not([data-dockkit-drop-active]) { - background: transparent; - border-color: var(--dsw-alias-border-l2); +[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] { + width: 50%; + padding-left: 4px; +} + +.dockHintCard { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + padding: 12px; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + border: 1.5px dashed var(--dsw-alias-border-l2); + border-radius: 12px; + transition: color 120ms ease-out, background-color 120ms ease-out, border-color 120ms ease-out; + animation: dockHintIn 140ms ease-out; +} + +/* The card under the pointer takes the accent: a tinted ground over the scrim, + an accent dashed frame, and secondary ink for the glyph and caption. */ +.dockHint[data-dockkit-drop-active] .dockHintCard { + color: var(--dsw-alias-label-secondary); + background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 8%, transparent); + border-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.dockHintCard svg { + flex: none; + width: 20px; + height: 20px; +} + +/* One line, clipped rather than wrapped: the card in a narrow half is still + wide enough for its glyph, and the caption fades under the frame. */ +.dockHintLabel { + max-width: 100%; + overflow: hidden; + font-size: var(--dsh-content-font-size-secondary, 13px); + font-weight: 500; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; +} + +@keyframes dockHintIn { + from { + opacity: 0; + transform: scale(0.98); + } + + to { + opacity: 1; + transform: none; + } +} + +/* The scrim fades in without the cards' scale: a full-body cover that shrinks + would expose an uncovered rim of raw content on entry. */ +@keyframes dockScrimIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } } .dockHint[data-dockkit-dock-zone='top'] { @@ -361,43 +640,50 @@ position: fixed; display: flex; flex-direction: column; - background: var(--dsw-alias-bg-layer-1); - border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 8px; - box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-drop); + /* The body inside is unpadded and reaches the edges, so the frame clips it + to its own corners. */ + overflow: hidden; + background: var(--dsw-alias-bg-layer-2); + border-radius: 20px; + /* The menu's shadow with the stroke rebound one step lighter than the + default (l2): the panel reads as the same kind of raised surface, and the + shadow's hairline outlines it, so the frame draws no border of its own. */ + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + box-shadow: var(--dsw-elevation-prominent); pointer-events: auto; } -/* The active panel keeps the same hairline: it is already on top of the z - order and casts the same shadow, and a heavier or darker frame read as a - defect. `data-dockkit-float-active` stays on the element for tests. */ +/* The active panel keeps the same frame: it is already on top of the z order + and casts the same shadow, and a heavier or darker frame read as a defect. + `data-dockkit-float-active` stays on the element for tests. */ +/* The panel's header is the pane strip's row (`.tabStrip` supplies the + metrics) and the whole of it is the move grip. */ .floatHeader { - display: flex; - flex: none; - gap: 2px; - align-items: center; - height: 28px; - padding: 0 4px 0 10px; - border-bottom: 0.5px solid var(--dsw-alias-border-l1); cursor: move; - touch-action: none; user-select: none; } +/* The one tab, drawn as its chip would be in a strip but never selected, + hovered, or closable from here: the frame's controls do that. */ .floatTitle { - flex: 1 1 auto; - overflow: hidden; - color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size-secondary, 13px); - white-space: nowrap; - text-overflow: ellipsis; + flex: 0 1 auto; + cursor: inherit; } +.tab.floatTitle:hover { + background: transparent; +} + +/* Mirrored so the filled pane sits at the right, where the docked surface is. */ +.dockGlyph { + transform: scaleX(-1); +} + +/* Unpadded like `.paneBody`: the same body draws the same insets in a float. */ .floatBody { flex: 1 1 auto; min-height: 0; - padding: 10px; overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller inside it rebinds the thumb indirection in a complete pair — a base-surface thumb on an elevated ground reads as a smudge. */ @@ -409,19 +695,35 @@ position: absolute; right: 0; bottom: 0; - width: 14px; - height: 14px; + width: 20px; + height: 20px; cursor: nwse-resize; touch-action: none; } +/* The grip is a quarter arc concentric with the frame's 20px corner (16px + radius at a 4px inset), so it reads as part of the frame. It shows only + while the pointer is over the panel — an idle panel keeps a clean corner — + and `:active` keeps it lit while a drag holds the pointer capture. */ .floatResize::after { - position: absolute; - right: 3px; - bottom: 3px; - width: 6px; - height: 6px; - border-right: 2px solid var(--dsw-alias-label-tertiary); - border-bottom: 2px solid var(--dsw-alias-label-tertiary); content: ''; + position: absolute; + right: 4px; + bottom: 4px; + width: 16px; + height: 16px; + border-right: 1.5px solid var(--dsw-alias-label-caption); + border-bottom: 1.5px solid var(--dsw-alias-label-caption); + border-bottom-right-radius: 16px; + opacity: 0; + transition: opacity 120ms ease-out; +} + +.float:hover .floatResize::after, +.floatResize:active::after { + opacity: 1; +} + +.floatResize:hover::after { + border-color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-dockkit/src/components/measure.ts b/packages/client/ui-dockkit/src/components/measure.ts index 6ce1fea4eb..c03961ba80 100644 --- a/packages/client/ui-dockkit/src/components/measure.ts +++ b/packages/client/ui-dockkit/src/components/measure.ts @@ -63,12 +63,31 @@ function dividerSize(root: HTMLElement): number { return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider } +/** + * The rendered split control's footprint in the strip's fixed part: its box + * plus the strip's own gap, both of which the strip sheds when the control + * hides. 0 while the control is hidden or unmeasured. + */ +function splitControlFootprint(pane: HTMLElement): number { + const control = pane.querySelector('[data-dockkit-split-button]') + if (control === null) return 0 + const width = control.getBoundingClientRect().width + if (!(width > 0)) return 0 + const strip = pane.querySelector('[data-dockkit-strip]') + /* v8 ignore next -- the control only renders inside a strip. */ + return width + (strip === null ? 0 : px(getComputedStyle(strip).columnGap)) +} + /** * Measure every docked pane under `root`. * @param root - the docked surface's element. + * @param splitHiddenWhenBlocked - whether the embedder hides blocked split + * controls (`hideSplitWhenBlocked`); the room rule then leaves the control's + * footprint out of each strip's fixed part, so the reading cannot flip with + * the control's visibility (see `PaneMeasure.splitControlWidth`). * @returns each pane's fit, keyed by pane id. */ -export function measurePaneFits(root: HTMLElement): ReadonlyMap { +export function measurePaneFits(root: HTMLElement, splitHiddenWhenBlocked = false): ReadonlyMap { const minimums: SplitMinimums = { divider: dividerSize(root), chip: chipMinimum(root), body: SPLIT_MINIMUMS.body } const fits = new Map() for (const [paneId, pane] of paneElements(root)) { @@ -77,6 +96,7 @@ export function measurePaneFits(root: HTMLElement): ReadonlyMap) => void /** Why a pane cannot split right now, or `undefined` while it can. */ readonly splitBlock: (paneId: PaneId) => SplitBlock | undefined - /** Hide budget-blocked split controls without hiding width-blocked controls. */ - readonly hideSplitAtCapacity?: boolean + /** Hide blocked split controls instead of rendering them disabled. */ + readonly hideSplitWhenBlocked?: boolean /** Whether a pane's strip draws the add control. */ readonly canAddTab: (paneId: PaneId) => boolean - /** Whether a tab's chip and menu offer close. */ + /** Whether a tab draws its close control and its menu's close item. */ readonly canCloseTab: (tabId: TabId) => boolean /** Live drop preview, or `undefined` while nothing is being dragged. */ readonly dropTarget: DropTarget | undefined diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index 3ca282dd3e..81239e816e 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -31,6 +31,8 @@ export interface DockLabels { readonly dockFloat: string /** Close a floating panel. */ readonly closeFloat: string + /** The drop hint's caption for each body zone a dragged tab can land on. */ + readonly dropZone: Readonly> } /** @@ -45,7 +47,9 @@ export type TabRenderer = (tab: TabRecord) => ReactNode * * The kit's own item is the close gesture; anything that means something about * the tab's content comes from here. An item that acts MUST call `dismiss`, - * because the menu closes on its own items only. + * because the menu closes on its own items only. Every rendered item MUST + * carry `role="menuitem"`: the kit probes for that role to dismiss a menu + * that would paint empty, so items without it count as an empty menu. * @param tab - the tab whose menu is open. * @param dismiss - close the menu without acting. * @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing. diff --git a/packages/client/ui-dockkit/src/engine/geometry.ts b/packages/client/ui-dockkit/src/engine/geometry.ts index ff32cd3208..973dd47be3 100644 --- a/packages/client/ui-dockkit/src/engine/geometry.ts +++ b/packages/client/ui-dockkit/src/engine/geometry.ts @@ -71,6 +71,17 @@ export interface PaneMeasure { readonly chipsWidth: number /** Width of the fill: free space, not a control. */ readonly fillWidth: number + /** + * Footprint of the rendered split control (its width plus the strip's gap) + * for embedders that hide blocked split controls: a half too narrow to + * split hides its own control, so the rule leaves the footprint out of the + * fixed part. Leaving it out is also what keeps the reading stable — the + * control hiding sheds the same footprint from the strip, and a reading + * that counted it would flip with the control's visibility and re-render + * forever. Absent or 0 keeps the control in the fixed part, for embedders + * that render a blocked control disabled. + */ + readonly splitControlWidth?: number } /** Pixel minimums the room rule holds each half to. */ @@ -79,18 +90,19 @@ export interface SplitMinimums { readonly divider: number /** One chip at its minimum: the smallest strip that still names a tab. */ readonly chip: number - /** The smallest body under a strip: one secondary text line inside the body's padding. */ + /** The smallest body under a strip: one secondary text line inside 12px of the body's own insets. */ readonly body: number } /** * The minimums where no computed style can be read, mirroring - * `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of - * content plus 10px + 5px of padding (content-box), 59px; the body's 12px - * padding above and below one 13px secondary line at 1.6 line-height is 45px, - * held to 48px. + * `dockkit.module.css`: `.splitRow > .divider` takes no layout width (its + * hairline is painted over the seam); `.tab` is 80px of content plus + * 10px + 10px of padding (content-box), 100px; 12px above and below one 13px + * secondary line at 1.6 line-height — the inset a body draws for itself, as + * `.empty` does — is 45px, held to 48px. */ -export const SPLIT_MINIMUMS: SplitMinimums = { divider: 4, chip: 59, body: 48 } +export const SPLIT_MINIMUMS: SplitMinimums = { divider: 0, chip: 100, body: 48 } /** Whether a pane's two halves after an equal split would each still work. */ export interface HalvesFit { @@ -102,9 +114,10 @@ export interface HalvesFit { /** * The room rule. After an equal split each half must hold what cannot shrink: - * horizontally the strip's fixed part — its width minus the chip box and the - * fill, which is the padding, the gaps, and every control that pane draws — - * plus one chip at its minimum; vertically the strip plus a minimum body. The + * horizontally the strip's fixed part — its width minus the chip box, the + * fill, and `splitControlWidth`, which is the padding, the gaps, and every + * control a half would still draw — plus one chip at its minimum; vertically + * the strip plus a minimum body. The * borders are what the pane's box exceeds the strip's by. An unmeasured pane * (no layout, as under jsdom) fits: the rule only blocks on a positive reading. * @param measure - the pane's rectangles. @@ -115,7 +128,7 @@ export function halvesFit(measure: PaneMeasure, minimums: SplitMinimums = SPLIT_ const { pane, strip } = measure if (!(pane.width > 0) || !(pane.height > 0) || !(strip.width > 0)) return { row: true, column: true } const borders = Math.max(0, pane.width - strip.width) - const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth) + const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth - (measure.splitControlWidth ?? 0)) const halfWidth = (pane.width - minimums.divider) / 2 - borders const halfHeight = (pane.height - minimums.divider) / 2 - borders return { diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 23b49d2237..85768ebb55 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -260,14 +260,30 @@ describe('DockSurface', () => { expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') }) - it('hides capacity-blocked split controls when opted in and restores them when capacity returns', () => { + it('names the enabled split control through the shared tooltip, not a native title', () => { + vi.useFakeTimers() + try { + renderSurface(seededController(), spyIntents()) + const button = screen.getByRole('button', { name: TEST_LABELS.splitPane }) + expect(button.hasAttribute('title')).toBe(false) + fireEvent.mouseEnter(button) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane) + fireEvent.mouseLeave(button) + expect(screen.queryByRole('tooltip')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('hides blocked split controls when opted in and restores them when capacity returns', () => { const controller = seededController() controller.splitPane() const state = controller.getSnapshot().state - layOut(dockPaneIds(state), 420) + layOut(dockPaneIds(state), 520) const intents = spyIntents() const props: DockSurfaceProps = { - state, canSplit: false, hideSplitAtCapacity: true, intents, labels: TEST_LABELS, renderTab: tab =>

{tab.title}

, + state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab =>

{tab.title}

, } const view = render() expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull() @@ -285,15 +301,15 @@ describe('DockSurface', () => { // jsdom lays nothing out, so the room rule reads the rectangles this spec // hands it: two panes, one wide enough for two halves and one not. The // strip's fixed part is 104px in both (the chrome pane's controls), the chip - // minimum falls back to the stylesheet's 59px. - it.each([false, true])('keeps the width-blocked split control and its title with hideSplitAtCapacity=%s', (hideSplitAtCapacity) => { + // minimum falls back to the stylesheet's 100px. + it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => { const controller = seededController() controller.setExpanded(true) controller.splitPane() const snapshot = controller.getSnapshot() const [wide, narrow] = dockPaneIds(snapshot.state) if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes') - const widths: Record = { [wide]: 420, [narrow]: 208 } + const widths: Record = { [wide]: 520, [narrow]: 208 } vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { const paneWidth = widths[this.closest('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0 if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) @@ -306,7 +322,7 @@ describe('DockSurface', () => {

{tab.contentId}

} @@ -315,10 +331,14 @@ describe('DockSurface', () => { const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`) const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`) expect(wideButton?.hasAttribute('disabled')).toBe(false) - expect(wideButton?.getAttribute('title')).toBe(TEST_LABELS.splitPane) - expect(narrowButton?.hasAttribute('disabled')).toBe(true) - expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) - expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') + expect(wideButton?.hasAttribute('title')).toBe(false) + if (hideSplitWhenBlocked) { + expect(narrowButton).toBeNull() + } else { + expect(narrowButton?.hasAttribute('disabled')).toBe(true) + expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) + expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') + } }) it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => { @@ -342,7 +362,7 @@ describe('DockSurface', () => { controller.splitPane() const snapshot = controller.getSnapshot() const panes = dockPaneIds(snapshot.state) - let paneWidth = 420 + let paneWidth = 520 vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) @@ -379,6 +399,194 @@ describe('DockSurface', () => { expect(observer.disconnect).toHaveBeenCalledTimes(1) }) + // The room reading must not flip with the hidden control's own footprint: + // hiding the width-blocked control widens the fill by the control plus the + // strip's gap, and a reading that counted the control would fit again, show + // it, and re-render forever (React's update-depth limit, which crashed the + // surface). The rule leaves the footprint out, so the fill the fake hands + // back here depends on whether the control is in the DOM — exactly the + // feedback the fix breaks. + it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => { + class FakeResizeObserver implements ResizeObserver { + static latest: FakeResizeObserver | undefined + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.latest = this + } + + /** What the platform does when the observed element's size changes. */ + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + const controller = seededController() + controller.setExpanded(true) + const snapshot = controller.getSnapshot() + // In the band where only the control's 28px footprint decides the fit: + // half 188px against 76px of other fixed controls plus the 100px chip. + let paneWidth = 380 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const pane = this.closest('[data-dockkit-pane]') + if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600) + if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36) + if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24) + if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28) + if (this.hasAttribute('data-dockkit-strip-fill')) { + const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104 + return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24) + } + return box(0, 0, 0, 0) + }) + render( +

{tab.contentId}

} + />, + ) + // Settled with the control shown: the discounted reading fits either way. + expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull() + + // Narrowed under the discounted minimum: hidden, and the reading without + // the control settles hidden. + const observer = FakeResizeObserver.latest + if (observer === undefined) throw new Error('expected the surface to observe its own size') + paneWidth = 300 + act(() => { observer.fire() }) + expect(document.querySelector('[data-dockkit-split-button]')).toBeNull() + }) + + it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => { + let scrollLeft = 0 + const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name => + [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const) + const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs') + Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } }) + Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } }) + Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } }) + try { + const controller = seededController() + renderSurface(controller, spyIntents()) + const box = document.querySelector('[data-dockkit-strip-tabs]') + if (box === null) throw new Error('expected the chip box') + expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end') + scrollLeft = 100 + fireEvent.scroll(box) + expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end') + scrollLeft = 200 + fireEvent.scroll(box) + expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start') + } finally { + for (const [name, descriptor] of descriptors) { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name) + else Object.defineProperty(Element.prototype, name, descriptor) + } + } + }) + + it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => { + let scrollLeft = 0 + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft') + Object.defineProperty(Element.prototype, 'scrollLeft', { + configurable: true, + get() { return scrollLeft }, + set(value: number) { scrollLeft = value }, + }) + // The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says. + let openedTab: TabId | undefined + let openedAt = 250 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28) + if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28) + if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28) + return box(0, 0, 0, 0) + }) + const surface = (controller: DockController) => ( +

{tab.contentId}

} + /> + ) + try { + const controller = seededController() + const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId + if (seededTab === undefined) throw new Error('expected the seeded tab') + const { rerender } = render(surface(controller)) + // The seeded chip is in view: nothing moves. + expect(scrollLeft).toBe(0) + openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }) + rerender(surface(controller)) + // Past the right edge by 140, plus the 24px fade. + expect(scrollLeft).toBe(164) + // Selected again once it lies 30 past the left edge: back by 30 plus the fade. + openedAt = -30 + controller.focusTab(seededTab) + rerender(surface(controller)) + controller.focusTab(openedTab) + rerender(surface(controller)) + expect(scrollLeft).toBe(164 - 30 - 24) + } finally { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft') + else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor) + } + }) + + it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => { + class FakeResizeObserver implements ResizeObserver { + static readonly all: FakeResizeObserver[] = [] + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.all.push(this) + } + + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + let clientWidth = 200 + const descriptors = ['scrollWidth', 'clientWidth'].map(name => + [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const) + const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title') + Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } }) + Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } }) + try { + const controller = seededController() + const { unmount } = renderSurface(controller, spyIntents()) + const span = document.querySelector('[data-dockkit-tab-title]') + if (span === null) throw new Error('expected a chip title') + const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span)) + if (observer === undefined) throw new Error('expected the title to observe its own size') + expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false) + // The chip narrowed under the text. + clientWidth = 80 + act(() => { observer.fire() }) + expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true) + clientWidth = 200 + act(() => { observer.fire() }) + expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false) + unmount() + expect(observer.disconnect).toHaveBeenCalledTimes(1) + } finally { + for (const [name, descriptor] of descriptors) { + if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name) + else Object.defineProperty(Element.prototype, name, descriptor) + } + vi.unstubAllGlobals() + } + }) + it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => { const controller = seededController() const intents = spyIntents() @@ -414,6 +622,59 @@ describe('DockSurface', () => { expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull() }) + it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => { + const controller = seededController() + controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) + const snapshot = controller.getSnapshot() + const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0] + if (seedTabId === undefined) throw new Error('expected seeded tab') + render( + tabId !== seedTabId} + intents={controller} + labels={TEST_LABELS} + renderTab={tab =>

{tab.contentId}

} + renderTabMenuItems={(_, dismiss) => } + />, + ) + const [seedChip, fileChip] = screen.getAllByRole('tab') + if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips') + expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull() + expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull() + + // The menu still opens on the withheld chip: the embedder's items remain reachable. + fireEvent.contextMenu(seedChip) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item']) + fireEvent.pointerDown(document.body) + + fireEvent.contextMenu(fileChip) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item']) + }) + + it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => { + const controller = seededController() + render( + false} + intents={controller} + labels={TEST_LABELS} + renderTab={tab =>

{tab.contentId}

} + renderTabMenuItems={() => undefined} + />, + ) + const [chip] = screen.getAllByRole('tab') + if (chip === undefined) throw new Error('expected the seeded chip') + // The lone unclosable chip draws quiet: no capsule, no hover fill. + expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true') + // The menu would hold nothing, so it dismisses itself before painting. + fireEvent.contextMenu(chip) + expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull() + }) + it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => { const controller = seededController() controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) @@ -648,13 +909,13 @@ describe('tab drags', () => { }) it('shows the dock hint on the pane under the pointer and reports the zone on release', () => { - const { intents, second, fileTabId, chip } = twoPanes() - drag(chip(fileTabId), FILE_CHIP, [420 + 210, 300], false) + const { intents, second, fileTabId, chip } = twoPanes(520) + drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false) const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`) expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center') - fireEvent.pointerMove(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) + fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 }) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right') - fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 }) + fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 }) expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right') expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() }) @@ -672,6 +933,8 @@ describe('tab drags', () => { const { intents, second, fileTabId, chip } = twoPanes(208) drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false) expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull() + fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 500 }) + expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom') fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top') fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 }) @@ -764,10 +1027,10 @@ describe('tab drags', () => { describe('horizontal workbench drops', () => { it.each([ - { x: 450, y: 300, zone: 'left' }, - { x: 810, y: 590, zone: 'right' }, + { x: 550, y: 300, zone: 'left' }, + { x: 1010, y: 590, zone: 'right' }, ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => { - const { intents, second, fileTabId, chip } = twoPanes(420, true, { dropZones: 'horizontal' }) + const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' }) drag(chip(fileTabId), FILE_CHIP, [x, y], false) const hints = document.querySelectorAll('[data-dockkit-dock-zone]') expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right']) diff --git a/packages/client/ui-dockkit/tests/fixtures.client.ts b/packages/client/ui-dockkit/tests/fixtures.client.ts index a4c87a52e8..04a71c35bc 100644 --- a/packages/client/ui-dockkit/tests/fixtures.client.ts +++ b/packages/client/ui-dockkit/tests/fixtures.client.ts @@ -44,6 +44,7 @@ export const TEST_LABELS: DockLabels = { addTab: 'new tab', dockFloat: 'dock', closeFloat: 'close panel', + dropZone: { center: 'move here', left: 'split left', right: 'split right', top: 'split top', bottom: 'split bottom' }, } /** Brand a literal a spec spells out: an id the kit would have minted. */ diff --git a/packages/client/ui-dockkit/tests/geometry.client.spec.ts b/packages/client/ui-dockkit/tests/geometry.client.spec.ts index 69f1e821b8..93b6a6e1e2 100644 --- a/packages/client/ui-dockkit/tests/geometry.client.spec.ts +++ b/packages/client/ui-dockkit/tests/geometry.client.spec.ts @@ -118,26 +118,26 @@ describe('halvesFit — the room rule', () => { }) it('needs each half to hold the strip\'s fixed controls plus one minimum chip', () => { - // 420px: halves of 206px inside the borders, against 104 + 59. - expect(halvesFit(measure(420, 600, 104)).row).toBe(true) - // 208px: halves of 100px, short of 163. + // 520px: halves of 258px inside the borders, against 104 + 100. + expect(halvesFit(measure(520, 600, 104)).row).toBe(true) + // 208px: halves of 102px, short of 204. expect(halvesFit(measure(208, 600, 104)).row).toBe(false) - // The boundary is inclusive: 2 * (163 + 2 borders) + 4 divider = 334. - expect(halvesFit(measure(334, 600, 104)).row).toBe(true) - expect(halvesFit(measure(333, 600, 104)).row).toBe(false) - // A strip with fewer controls needs less. - expect(halvesFit(measure(208, 600, 44)).row).toBe(false) - expect(halvesFit(measure(214, 600, 44)).row).toBe(true) + // The boundary is inclusive: 2 * (204 + 2 borders) = 412; the divider takes no room. + expect(halvesFit(measure(412, 600, 104)).row).toBe(true) + expect(halvesFit(measure(411, 600, 104)).row).toBe(false) + // A strip with fewer controls needs less: 2 * (144 + 2) = 292. + expect(halvesFit(measure(291, 600, 44)).row).toBe(false) + expect(halvesFit(measure(292, 600, 44)).row).toBe(true) }) it('needs each half to hold the strip plus a minimum body for a column split', () => { - // Halves of (h - 4) / 2 - 2 against 36 + 48 = 84. - expect(halvesFit(measure(420, 176, 104)).column).toBe(true) - expect(halvesFit(measure(420, 175, 104)).column).toBe(false) + // Halves of h / 2 - 2 against 36 + 48 = 84. + expect(halvesFit(measure(420, 172, 104)).column).toBe(true) + expect(halvesFit(measure(420, 171, 104)).column).toBe(false) }) it('takes the minimums it is given, and the stylesheet\'s by default', () => { - expect(SPLIT_MINIMUMS).toEqual({ divider: 4, chip: 59, body: 48 }) + expect(SPLIT_MINIMUMS).toEqual({ divider: 0, chip: 100, body: 48 }) expect(halvesFit(measure(208, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(false) expect(halvesFit(measure(220, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(true) }) diff --git a/packages/client/ui-dockkit/tests/measure.client.spec.ts b/packages/client/ui-dockkit/tests/measure.client.spec.ts index 52f30d3237..85342bb255 100644 --- a/packages/client/ui-dockkit/tests/measure.client.spec.ts +++ b/packages/client/ui-dockkit/tests/measure.client.spec.ts @@ -57,21 +57,21 @@ describe('measurePaneFits', () => { const root = surface() const bare = document.createElement('section') bare.dataset.dockkitPane = 'bare' - root.append(pane('wide', 420), bare) + root.append(pane('wide', 520), bare) expect(paneElements(root).map(([id]) => id)).toEqual(['wide', 'bare']) const fits = measurePaneFits(root) expect(fits.get(asPane('wide'))).toEqual({ row: true, column: true }) expect(fits.get(asPane('bare'))).toEqual({ row: true, column: true }) }) - // 308px: halves of 150px inside the borders, against 104px of controls plus one chip. + // 308px: halves of 152px inside the borders, against 104px of controls plus one chip. it('reads the chip minimum from a rendered chip\'s computed style, padding included for a content box', () => { const root = surface() const narrow = pane('p', 308) root.append(narrow) - // No chip rendered: the stylesheet's 59px, so 163 > 150. + // No chip rendered: the stylesheet's 100px, so 204 > 152. expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) - // 44px of content plus 4px + 4px of padding is 52: 156 > 150. + // 44px of content plus 4px + 4px of padding is 52: 156 > 152. const rendered = chip(narrow, { minWidth: '44px', paddingLeft: '4px', paddingRight: '4px', boxSizing: 'content-box' }) expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) // The same declaration as a border box is the whole footprint: 148 fits. @@ -84,10 +84,10 @@ describe('measurePaneFits', () => { expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false) }) - // 336px: halves of 164px against 163 with the stylesheet's 4px divider, 162 with an 8px one. + // 416px: halves of 204px against 204 with the stylesheet's zero divider, 202 with an 8px one. it('reads the divider\'s thickness from a rendered divider, and the stylesheet\'s before one exists', () => { const root = surface() - root.append(pane('p', 336)) + root.append(pane('p', 416)) expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true) const divider = document.createElement('div') divider.dataset.dockkitDivider = 's:0' diff --git a/packages/client/ui-dockkit/tsconfig.json b/packages/client/ui-dockkit/tsconfig.json index 2139bcf7ba..82162fc41c 100644 --- a/packages/client/ui-dockkit/tsconfig.json +++ b/packages/client/ui-dockkit/tsconfig.json @@ -10,6 +10,9 @@ "references": [ { "path": "../../util/brand" + }, + { + "path": "../ui-primitives" } ] } diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css b/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css index 6ca91f9c86..4b898967c9 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css @@ -1,13 +1,13 @@ -/* The split button sits beside the session-log capsule at the same compact - scale (26px tall, pill radius, hairline l4 border, 11px primary-color label). */ +/* The split button matches the header's 28px control row (pill radius, + hairline l4 border, 11px primary-color label) beside the round icon buttons. */ .split { display: inline-flex; align-items: stretch; box-sizing: border-box; - height: 26px; + height: 28px; border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 13px; + border-radius: 14px; overflow: hidden; font-family: var(--dsw-font-family); } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 4f9173f729..138d94440f 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7b670394f6ca70ea369fc79766b26119f670f753 -README.zh.md: 8ddefde928a723992945f801fb7407a2d22f5f27 +README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 +README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7b670394f6..8fd59c5ecc 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -55,6 +55,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | +| `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | Three pairs are easy to confuse: @@ -66,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 8ddefde928..2478330825 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -55,6 +55,7 @@ kind: "package-library" | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | +| `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | 有三组容易混淆: @@ -66,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx new file mode 100644 index 0000000000..f03c01d4a7 --- /dev/null +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -0,0 +1,167 @@ +/** + * The coloured file-type sheet: a 28×28 paper with a folded corner, filled in + * the type's colour, carrying a white mark for the type. Unlike the `ic_ds_*` + * glyphs it does not ride `currentColor` — the colour is the type's identity — + * so a consumer that wants it muted applies a CSS filter. Design sources: + * DSHFiles iconCode, iconHTML, iconImage, iconMD, iconPDF, iconExcel, + * iconPPT, iconWord, iconOthers. + * + * TODO: interim. The sheets and `classifyFileType` are drawn for the Sidebar + * only; the product-wide file-type standard that will replace them is described + * in `file-extensions.ts`. + */ +import type { ReactNode } from 'react' +import { + CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, + SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension, +} from './file-extensions.ts' +import type { IconProps } from './icons/props.ts' + +/** File types with their own sheet colour and mark; `other` is the grey sheet with text lines. */ +export type FileTypeKind = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'sheet' | 'slides' | 'document' | 'other' + +/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */ +export interface FileTypeIconProps extends IconProps { + kind: FileTypeKind +} + +/** Extension sets in precedence order: a specific type wins over the code set it may also sit in. */ +const KIND_SETS: readonly (readonly [FileTypeKind, ReadonlySet])[] = [ + ['markdown', MARKDOWN_EXTENSIONS], + ['html', HTML_EXTENSIONS], + ['pdf', PDF_EXTENSIONS], + ['sheet', SHEET_EXTENSIONS], + ['slides', SLIDES_EXTENSIONS], + ['document', WORD_EXTENSIONS], + ['image', IMAGE_EXTENSIONS], + ['code', CODE_EXTENSIONS], +] + +/** + * Derive a file path's type glyph from its extension. Unknown and missing + * extensions fall to `other`. + * @param path - File path or bare name, with either separator. + * @returns The file's type. + */ +export function classifyFileType(path: string): FileTypeKind { + const extension = fileExtension(path) + return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other' +} + +const SHEET_PATH = 'M8.48949 28H19.511C21.6482 28 22.7167 28 23.5596 27.6509C24.6835 27.1853 25.5764 26.2924 26.042 25.1685C26.3911 24.3256 26.3911 23.257 26.3911 21.1199V8.79443C26.3911 8.32877 26.3911 8.09593 26.3473 7.87507C26.2889 7.58058 26.1733 7.30042 26.007 7.05048C25.8822 6.86303 25.718 6.69799 25.3895 6.36792L20.0613 1.01354C19.7307 0.681235 19.5653 0.515081 19.3771 0.38885C19.1263 0.220541 18.8446 0.103463 18.5483 0.0443412C18.3261 0 18.0917 0 17.6229 0H8.48949C6.35233 0 5.28376 0 4.44085 0.349145C3.31697 0.814671 2.42405 1.70759 1.95852 2.83147C1.60938 3.67438 1.60938 4.74296 1.60938 6.88011V21.1199C1.60938 23.257 1.60938 24.3256 1.95852 25.1685C2.42405 26.2924 3.31697 27.1853 4.44085 27.6509C5.28376 28 6.35233 28 8.48949 28Z' + +const CORNER_PATH = 'M26.3911 7.37445L19.0527 0V3.77445C19.0527 4.89271 19.0527 5.45184 19.2354 5.89289C19.479 6.48096 19.9462 6.94818 20.5343 7.19176C20.9753 7.37445 21.5345 7.37445 22.6527 7.37445H26.3911Z' + +/** One type's sheet colour and its white mark. */ +interface Face { + readonly fill: string + readonly mark: ReactNode +} + +const FACES: Readonly> = { + code: { + fill: '#4176E6', + mark: ( + <> + + + + ), + }, + html: { + fill: '#4176E6', + mark: ( + + ), + }, + image: { + fill: '#8B76F6', + mark: ( + <> + + + + + ), + }, + markdown: { + fill: '#4176E6', + mark: ( + + ), + }, + pdf: { + fill: '#EC1313', + mark: ( + + ), + }, + sheet: { + fill: '#22C55E', + mark: ( + + ), + }, + slides: { + fill: '#F59E0B', + mark: ( + + ), + }, + document: { + fill: '#5686FE', + mark: ( + + ), + }, + other: { + fill: '#CFD3D6', + mark: ( + <> + + + + + ), + }, +} + +/** + * Render one file type's coloured sheet. + * @param props - The type, optional size (default 28px, the drawn size), and + * optional CSS class. + * @returns The type's SVG, `aria-hidden`; the adjacent name carries the meaning. + */ +export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode { + const face = FACES[kind] + return ( + + + {face.mark} + {/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */} + {kind === 'other' + ? + : } + + ) +} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 093e8e4cad..37dade0529 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -9,6 +9,10 @@ * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. */ import type { ReactNode } from 'react' +import { + CODE_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS, + fileExtension, +} from './file-extensions.ts' import type { IconProps } from './icons/props.ts' /** @@ -24,36 +28,22 @@ export interface LinkIconProps extends IconProps { kind: LinkIconKind } -/** Code, web, and data extensions: all three categories share the code glyph. */ -const CODE_EXTENSIONS = new Set([ - 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', - 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', - 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', - 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', - 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', -]) - -const IMAGE_EXTENSIONS = new Set([ - 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', -]) - -const DOCUMENT_EXTENSIONS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) +/** Office-style documents share one paper-doc glyph on a link. */ +const DOCUMENT_SETS = [PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS] /** - * Derive a file path's link-icon category from its extension. Unknown and - * missing extensions fall to `other` (the plain-paper glyph). + * Derive a file path's link-icon category from its extension. Code, web, and + * data files share the code glyph; unknown and missing extensions fall to + * `other` (the plain-paper glyph). * @param path - File path as the producing tool spelled it (either separator). * @returns The file's glyph category; never `url` or `folder`. */ export function classifyLinkPath(path: string): LinkIconKind { - const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const dot = name.lastIndexOf('.') - if (dot < 0) return 'other' - const extension = name.slice(dot + 1).toLowerCase() + const extension = fileExtension(path) + if (extension === '') return 'other' if (CODE_EXTENSIONS.has(extension)) return 'code' if (IMAGE_EXTENSIONS.has(extension)) return 'image' - return DOCUMENT_EXTENSIONS.has(extension) ? 'document' : 'other' + return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other' } const GlobeGlyph = ({ size, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/file-extensions.ts b/packages/client/ui-primitives/src/file-extensions.ts new file mode 100644 index 0000000000..8adbf67af2 --- /dev/null +++ b/packages/client/ui-primitives/src/file-extensions.ts @@ -0,0 +1,59 @@ +/** + * File-extension vocabularies shared by the glyph pickers (`classifyLinkPath`, + * `classifyFileType`), so a path classifies the same way on a link and on a + * file-type icon. + * + * TODO: this is an interim rule. The product will settle one extension + * standard for every place a file is drawn — an upload in the input bar, a + * sent attachment, an artifact, the Sidebar's tree rows, the Sidebar's tab + * chips and pane headers — and these sets, the glyph pickers over them, and + * the sheets in `FileTypeIcon` are to be replaced by it. Until then the + * surfaces differ: the Sidebar classifies by these sets, the conversation's + * links fold the four office types into `document` (`LinkIcon`), and the + * attachment cards (`ui-attachment` `FileCard`) draw one generic glyph with no + * classification at all. + */ + +/** Code, web, and data extensions. */ +export const CODE_EXTENSIONS: ReadonlySet = new Set([ + 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', + 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', + 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', + 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', + 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', + 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', +]) + +/** Raster and vector image extensions. */ +export const IMAGE_EXTENSIONS: ReadonlySet = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', +]) + +/** Web page extensions; a subset of {@link CODE_EXTENSIONS} with its own file-type glyph. */ +export const HTML_EXTENSIONS: ReadonlySet = new Set(['html', 'htm']) + +/** Markdown extensions. */ +export const MARKDOWN_EXTENSIONS: ReadonlySet = new Set(['md', 'mdx', 'markdown']) + +/** PDF. */ +export const PDF_EXTENSIONS: ReadonlySet = new Set(['pdf']) + +/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */ +export const SHEET_EXTENSIONS: ReadonlySet = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers']) + +/** Slide-deck extensions. */ +export const SLIDES_EXTENSIONS: ReadonlySet = new Set(['ppt', 'pptx', 'key']) + +/** Word-processor document extensions. */ +export const WORD_EXTENSIONS: ReadonlySet = new Set(['doc', 'docx', 'rtf', 'odt', 'pages']) + +/** + * A path's lowercase extension without the dot, taken from its last segment. + * @param path - File path with either separator. + * @returns The extension, or `''` for a name without a dot. + */ +export function fileExtension(path: string): string { + const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const dot = name.lastIndexOf('.') + return dot < 0 ? '' : name.slice(dot + 1).toLowerCase() +} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4afc31089f..23ad8cebb9 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -241,10 +241,12 @@ export const IconCopyOutline16 = ({ size = 16, className }: IconProps) => ( ) -/** ic_ds_refresh_outline_16 */ +/** ic_ds_refresh_outline_16, inset 10%: the exported glyph fills its box edge + * to edge, one visual size above the neighbouring 16px glyphs. */ export const IconRefreshOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 737e8fc879..d9b12eda0e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -33,6 +33,8 @@ export { ReferenceIcon } from './ReferenceIcon.tsx' export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx' export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx' export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx' +export { FileTypeIcon, classifyFileType } from './FileTypeIcon.tsx' +export type { FileTypeKind, FileTypeIconProps } from './FileTypeIcon.tsx' export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' diff --git a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx new file mode 100644 index 0000000000..306a9e7941 --- /dev/null +++ b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { FileTypeIcon, classifyFileType, type FileTypeKind } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +const KINDS: FileTypeKind[] = ['code', 'html', 'image', 'markdown', 'pdf', 'sheet', 'slides', 'document', 'other'] + +describe('classifyFileType', () => { + it.each([ + ['src/render.tsx', 'code'], + ['site/index.HTML', 'html'], + ['README.md', 'markdown'], + ['data/export.csv', 'sheet'], + ['book.xlsx', 'sheet'], + ['shots/hero.png', 'image'], + ['report.pdf', 'pdf'], + ['deck.pptx', 'slides'], + ['C:\\work\\summary.docx', 'document'], + ['notes.unknownext', 'other'], + ['Makefile', 'other'], + ['archive.tar/.hidden', 'other'], + ] as [string, FileTypeKind][])('%s → %s', (path, kind) => { + expect(classifyFileType(path)).toBe(kind) + }) +}) + +describe('FileTypeIcon', () => { + it.each(KINDS)('%s draws the shared sheet in its own colour, aria-hidden', (kind) => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg.getAttribute('aria-hidden')).toBe('true') + expect(svg.getAttribute('width')).toBe('28') + expect(svg.querySelector('path')!.getAttribute('fill')).toMatch(/^#[0-9A-F]{6}$/) + }) + + it('every kind carries a distinct mark', () => { + const marks = KINDS.map((kind) => { + const { container } = render() + const [, mark] = Array.from(container.querySelectorAll('path, rect')) + return `${mark!.getAttribute('d') ?? ''}${mark!.getAttribute('y') ?? ''}` + }) + expect(new Set(marks).size).toBe(KINDS.length) + }) + + it('folds a darker corner on the grey sheet and a translucent one elsewhere', () => { + const grey = render().container.querySelectorAll('path') + expect(grey[grey.length - 1]!.getAttribute('fill')).toBe('#A2A4A6') + const blue = render().container.querySelectorAll('path') + expect(blue[blue.length - 1]!.getAttribute('fill-opacity')).toBe('0.7') + }) + + it('size and className land on the svg', () => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg.getAttribute('width')).toBe('16') + expect(svg.getAttribute('height')).toBe('16') + expect(svg.classList.contains('x')).toBe(true) + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 29cc626b7e..f18db83c80 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: b4bf0cadce772f3ae596f8e62ab98e33db82cb46 -README.zh.md: f4eea40230ed9d3ed7669b9980491dfe0afab79e +README.md: 5b258c154258cee170b757c8a8a9599b61998bb4 +README.zh.md: 1c18763770df7c73b5b41fff3334c045e5204d3b diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index b4bf0cadce..5b258c1542 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -26,8 +26,8 @@ Preview readable files in the right Sidebar and choose among registered renderer ## What it registers -- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path, and a dropdown for matching renderers plus plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling. +- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. @@ -43,7 +43,7 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads: - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content. -- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. Failures retain loaded content and offer a retry. +- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, explanation, and retry; a later failure retains loaded content and adds the retry below it. - **Complete bytes** — PDF and HTML use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. - **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice. @@ -74,7 +74,7 @@ No direct effect; what the user reads here never enters a model request. - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF and HTML require a complete result within the Host's `maxFileBytes` cap. - **Byte-view scroll state is not restored.** PDF and HTML can return to the top when their renderer remounts or reloads; HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. -- **Package-local wrap glyph.** `IconWrapOutline16` lives in `src/client/icons.tsx` until the shared icon set carries one; the props contract already matches. +- **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract. - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index f4eea40230..1c18763770 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -26,8 +26,8 @@ kind: "package-reference" ## 注册了什么 -- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径,并提供匹配渲染器及纯文本的下拉选择。仅当所选渲染器声明 `wrap: true` 时显示换行开关;该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。 +- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。 @@ -43,7 +43,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路 正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取: - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。 -- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。失败时保留已加载内容并提供重试。 +- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明与重试按钮填满正文;较晚的失败保留已有内容并在其下提供重试。 - **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 - **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。 @@ -74,7 +74,7 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"` - **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。 - **字节视图不恢复滚动位置。** PDF 与 HTML 的渲染器重新挂载或重新载入时可能回到顶部;HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 -- **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。 +- **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 49397c41be..8d28a2f132 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -9,24 +9,57 @@ min-height: 0; } -/* One row: the path, then the type's controls at its end. */ +/* One 38px row flush under the pane's strip: the path, then the type's + controls at its end. With the strip's 38px above, its rule lies where the + conversation header's does (ui-conversation ConversationRoot `.header`, + 76px with the same rule), so the two lines meet at the column edge. + + The same row, path, and controls are drawn by the files tab + (ui-sidebar-files FilesBody `.header`); TODO: once the artifact and slot + surfaces settle, one copy in ui-primitives could serve every pane header. */ .header { display: flex; flex: 0 0 auto; - gap: 2px; + gap: 4px; align-items: center; - padding: 3px 6px 3px 10px; - border-bottom: 0.5px solid var(--dsw-alias-border-l1); + box-sizing: border-box; + height: 38px; + padding: 0 6px 0 16px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); } +/* The path is never ellipsized. Its text sits at the left while it fits: the + auto margin takes the free space. Once it is wider than the box, the auto + margin is zero and `flex-end` holds the name at the right edge, so the + directories run off the left and the box clips them there, faded by the + mask while `data-textpreview-path-clipped` is set. The margin plus the row's + gap put the text 16px clear of the first control. */ .path { + display: flex; flex: 1 1 auto; + justify-content: flex-end; min-width: 0; + margin-right: 12px; overflow: hidden; - color: var(--dsw-alias-label-tertiary); font-size: 12px; white-space: nowrap; - text-overflow: ellipsis; +} + +.path[data-textpreview-path-clipped] { + mask-image: linear-gradient(to right, transparent, black 28px); +} + +.pathText { + flex: none; + margin-right: auto; +} + +.pathDirectory { + color: var(--dsw-alias-label-tertiary); +} + +.pathName { + color: var(--dsw-alias-label-primary); } /* Announced, not applied: the reader keeps the text they are looking at. */ @@ -48,8 +81,13 @@ position: relative; flex: 1 1 auto; min-height: 0; - padding: 10px 0; + /* The files tree's inset and 2px scrollbar offset (ui-sidebar-files + FilesBody `.body`), so the two bodies start their text at one edge and + both bars sit 2px clear of the pane's edge. */ + margin-right: 2px; + padding: 8px 0 8px 8px; overflow: auto; + scrollbar-gutter: stable; /* The notice and retry surfaces in this sheet are elevated, so the file body's scroller rebinds the thumb indirection in a complete pair. */ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); @@ -61,6 +99,13 @@ white-space: pre; } +/* The bar also stops 2px short of the body's ends, matching its 2px edge + offset (ui-sidebar-files FilesBody keeps the same rule). WebKit-only: the + Firefox path has no track to inset. */ +.body::-webkit-scrollbar-track { + margin: 2px; +} + .wrap { white-space: pre-wrap; word-break: break-word; @@ -78,6 +123,7 @@ white-space: inherit; } +/* The files tree row's 10px side inset, so a line's text sits where a row's does. */ .line { padding: 0 10px; } @@ -86,6 +132,74 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* The failure with nothing read yet: the whole body, centred a little above + the middle (the spacer below). Fills the body's content box exactly (a + percentage height resolves against it), so the body does not scroll. The + body's monospace `pre` setting is undone here. */ +.empty { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 100%; + padding: 0 24px; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-family: var(--dsw-font-family); + line-height: 1.6; + text-align: center; + white-space: normal; +} + +/* The lift, as a share of the pane's height (a column flex basis resolves + against it): the centred column rises 6% of the pane, and a short pane + gives it up before the content clips. */ +.empty::after { + content: ''; + flex: 0 1 12%; +} + +/* The type's sheet keeps its shape but gives up its colour. */ +.emptyIcon { + flex: none; + opacity: 0.6; + filter: grayscale(1); +} + +.emptyLine { + margin: 0; +} + +/* A capsule holding the refresh glyph and the retry word. `flex: none` keeps + its height when the empty column runs out of room in a short panel. */ +.retry { + display: inline-flex; + flex: none; + gap: 6px; + align-items: center; + height: 32px; + padding: 0 14px 0 12px; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-family: inherit; + background: transparent; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 16px; + cursor: pointer; +} + +.retry:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The type's 16px sheet before the chip's text; the chip's title row centres + it on the text's line and spaces it. */ +.titleIcon { + flex: none; +} + .statusLine { display: flex; gap: 10px; @@ -148,31 +262,33 @@ } /* Controls in the header row, sized like the docking kit's own pane controls. */ +/* The same icon button as the Sidebar's strip controls above it: a 28px + circle around a 15px glyph. */ .tool { - display: flex; + display: inline-flex; + flex: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; - padding: 0; + width: 28px; + height: 28px; + padding: 6px; color: var(--dsw-alias-label-secondary); line-height: 1; background: transparent; border: none; - border-radius: 4px; + border-radius: 28px; cursor: pointer; } +.tool svg { + width: 15px; + height: 15px; +} + .tool:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } - -.toolOn { - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); -} - .viewerTool { width: auto; max-width: 160px; diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 8e47f7decc..31e76a4695 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -10,15 +10,16 @@ * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of * the path row; the Sidebar's strip carries none of them. */ -import { useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import { IconRefreshOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { FileTypeIcon, IconRefreshOutline16, Menu, Tooltip, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives' +import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { TextInjected } from './face.ts' import { failureLine } from './failure-line.ts' -import { IconWrapOutline16 } from './icons.tsx' +import { IconNowrapFill16, IconWrapFill16 } from './icons.tsx' import { LoadingIndicator } from './LoadingIndicator.tsx' import { hostFileOf } from './rpc.ts' import type { TextStore } from './store.ts' @@ -32,6 +33,29 @@ import css from './TextPreview.module.css' export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' export type { LoadedPage } from './text/lines.ts' +/** Keep the path fade in sync with whether its full text fits the header row. */ +function usePathClipped( + box: RefObject, + text: RefObject, + path: string, + shown: boolean, +): void { + useLayoutEffect(() => { + const outer = box.current + const inner = text.current + if (outer === null || inner === null) return undefined + const apply = (): void => { + if (inner.offsetWidth > outer.clientWidth) outer.dataset.textpreviewPathClipped = '' + else delete outer.dataset.textpreviewPathClipped + } + apply() + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply) + observer?.observe(outer) + observer?.observe(inner) + return () => { observer?.disconnect() } + }, [box, text, path, shown]) +} + /** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */ export interface TextPreviewInjected extends TextInjected { readonly hooks: { readonly documentPreviews: ObservableSnapshot } @@ -70,7 +94,11 @@ export function TextPreview({ const mode = selected?.loading const current = (state?.mode ?? 'text-pages') === mode ? state : undefined const bodyRef = useRef(null) + const pathRef = useRef(null) + const pathTextRef = useRef(null) const [menuOpen, setMenuOpen] = useState(false) + const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path + usePathClipped(pathRef, pathTextRef, displayPath, state !== undefined) // Every tab of this type is a `file` resource address, so its params are the // `file` type's; the union is narrowed on the one field read, not validated. const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined @@ -142,14 +170,13 @@ export function TextPreview({ ) } const next = loadedThrough + 1 - const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path + const { directory, name } = pathPartsOf(displayPath) const observedVersion = meta.value?.version const changed = current?.version !== undefined && observedVersion !== undefined && observedVersion !== current.version && observedVersion !== current.observedVersion const loadNext = (): void => { if (!canRead || current?.loading || current?.eof) return - if (mode === 'text-pages') loadPage(tab.id, file, next, signal, meta.value?.version) - else loadAll(tab.id, file, signal, meta.value?.version) + loadPage(tab.id, file, next, signal, meta.value?.version) } const reload = (): void => { if (!canRead) return @@ -158,10 +185,12 @@ export function TextPreview({ } return (
- {meta.failure !== undefined + {meta.failure !== undefined && hasContent ? ( // The file's metadata failed — gone, or its workspace unknown — which // outranks a pending change; the pages already read stay under it. + // With nothing read the body's own failure already says it, so the + // bar would only repeat the same line.

{failureLine(t, meta.failure)} + + )} + - )} - +

{t('rendererUnavailable', { name: selected.title() })}

, })} - {current?.failure !== undefined && ( -

- {failureLine(t, current.failure)} - -

- )} + {current?.failure !== undefined && (hasContent + ? ( +

+ {failureLine(t, current.failure)} + +

+ ) + : ( + // With no content, retry the selected renderer's read; metadata + // observation remains owned by the resource provider. +
+ +

{failureLine(t, current.failure)}

+ +
+ ))} {mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && ( @@ -140,6 +174,9 @@ export function FilesBody({ const { signal, actions: tabActions } = tab const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd) const state = useStore(store => store.byTab[tab.id]) + const pathRef = useRef(null) + const pathTextRef = useRef(null) + usePathClipped(pathRef, pathTextRef, state?.root) useEffect(() => { // A bucket gone because the record aborted must not be re-seeded by a // component that has not unmounted yet. @@ -168,13 +205,17 @@ export function FilesBody({ actions.reset(tab.id) for (const path of state.expanded) load(tab.id, path, signal) } - // A separator-only root has no final segment; the root itself is the label then. - const title = workspaceTitleOf(state.root) || state.root + const { directory, name } = pathPartsOf(state.root) return (
+ {/* jscpd:ignore-start -- the text preview's header row; see `usePathClipped`. */}
- - {title} +
+ + {directory !== '' && {directory}} + {name} + +
-
+ {/* jscpd:ignore-end */} +
+
+
) } diff --git a/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx b/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx new file mode 100644 index 0000000000..2d03a997df --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx @@ -0,0 +1,37 @@ +/** + * The files type's chip title: the folder sheet before the type's label. + * Registered under `sidebar.right.pane.tab.title`; without it the chip would + * show the bare label. The tree in the body draws its own row glyphs and never + * this one. + */ +import type { ReactNode } from 'react' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './FilesBody.module.css' + +/** The folder: a 28-unit sheet in the folder colour, a white tab line across it. */ +function FolderGlyph(): ReactNode { + return ( + + ) +} + +/** + * The title as the chip and a floating panel's header show it. + * @param props - the tab information hook. + * @returns the folder sheet followed by the tab's title text. + */ +export function FilesTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode { + const { tab } = useTabInfo() + return ( + <> + + {tab.title} + + ) +} diff --git a/packages/client/ui-sidebar-files/src/client/definition.ts b/packages/client/ui-sidebar-files/src/client/definition.ts index f967a472a0..00b475c829 100644 --- a/packages/client/ui-sidebar-files/src/client/definition.ts +++ b/packages/client/ui-sidebar-files/src/client/definition.ts @@ -30,7 +30,6 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab guide: [{ order: 10, title: () => t('guide.title'), - description: () => t('guide.description'), icon: IconFolderClose16, }], } diff --git a/packages/client/ui-sidebar-files/src/client/index.ts b/packages/client/ui-sidebar-files/src/client/index.ts index 0f04544729..5f5b391a80 100644 --- a/packages/client/ui-sidebar-files/src/client/index.ts +++ b/packages/client/ui-sidebar-files/src/client/index.ts @@ -2,12 +2,13 @@ * Browser half: register `files` as a right-Sidebar tab type. * * The public two-stage path, unmodified: the type into `ctx.sidebarRightTabs`, - * the body into the keyed `sidebar.right.pane.tab` seat under the type's `id`. + * the body into the keyed `sidebar.right.pane.tab` seat and the chip title into + * the keyed `sidebar.right.pane.tab.title` seat, both under the type's `id`. * * The file split is this package's layering: what the type IS * (`definition.ts`), what it keeps (`store.ts`), how it lists (`face.ts`), what - * it draws (`FilesBody.tsx`), what it says (`locales.ts`), and this module, - * which only wires them together. + * it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`), + * and this module, which only wires them together. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' @@ -17,6 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import { FILES_ID, filesDefinition } from './definition.ts' import { createList, filesFace } from './face.ts' import { FilesBody } from './FilesBody.tsx' +import { FilesTitle } from './FilesTitle.tsx' import { en, zh } from './locales.ts' import { createFilesStore } from './store.ts' @@ -35,7 +37,7 @@ const NS = 'sidebarFiles' export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles'] /** - * Client plugin body: register the type, its dictionaries, then its body. + * Client plugin body: register the type, its dictionaries, its body, and its chip title. * @param ctx - client root context carrying the registry, the slots, and the Remote face. */ export function apply(ctx: ClientContext): void { @@ -49,4 +51,8 @@ export function apply(ctx: ClientContext): void { { name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject }, FilesBody, )), 'ui-sidebar-files: files tab body') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab.title', key: FILES_ID }, + FilesTitle, + )), 'ui-sidebar-files: files tab title') } diff --git a/packages/client/ui-sidebar-files/src/client/locales.ts b/packages/client/ui-sidebar-files/src/client/locales.ts index 1976add093..dfca3151de 100644 --- a/packages/client/ui-sidebar-files/src/client/locales.ts +++ b/packages/client/ui-sidebar-files/src/client/locales.ts @@ -21,8 +21,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { 'type.label': '文件', - 'guide.title': '文件', - 'guide.description': '浏览这个会话工作区里的文件,点开就能查看。', + 'guide.title': '工作区文件', loading: '正在读取…', empty: '空目录', truncated: '条目太多,只显示了一部分。', @@ -41,11 +40,10 @@ export type SidebarFilesKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { 'type.label': 'Files', - 'guide.title': 'Files', - 'guide.description': 'Browse the files in this session\'s workspace and open any of them.', + 'guide.title': 'Workspace files', loading: 'Reading…', empty: 'Empty directory', - truncated: 'Too many entries; showing only some of them.', + truncated: 'Too many entries, showing only some of them.', noWorkspace: 'This session has no workspace directory.', reload: 'Reload', 'entry.other': 'Not a file or a directory, so it cannot be opened.', diff --git a/packages/client/ui-sidebar-files/tests/apply.client.spec.ts b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts index 8ab773ae13..13f29d498b 100644 --- a/packages/client/ui-sidebar-files/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts @@ -14,6 +14,7 @@ import { FILES_ID, FILES_KIND } from '../src/client/definition.ts' import { apply, inject } from '../src/client/index.ts' import { apply as hostApply } from '../src/index.ts' import { FilesBody } from '../src/client/FilesBody.tsx' +import { FilesTitle } from '../src/client/FilesTitle.tsx' import { en, zh } from '../src/client/locales.ts' interface Recorded { @@ -62,18 +63,19 @@ describe('ui-sidebar-files apply', () => { expect(hostApply).not.toThrow() }) - it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => { + it('registers the type, its dictionaries, and the body and title seats under the type\'s id', async () => { const { tabs, registered, dictionaries } = await boot() const definition = tabs.get(FILES_KIND) expect(definition?.id).toBe(FILES_ID) expect(definition?.priority).toBe('builtin') expect(definition?.title('sidebar://files')).toBe('type.label') - expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description()])).toEqual([[10, 'guide.title', 'guide.description']]) + expect(definition?.guide?.map(entry => [entry.order, entry.title()])).toEqual([[10, 'guide.title']]) expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en }) // The seat key is the implementation's id, not the kind: an extension may // take the kind over, and the seat must still find this body. expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ ['sidebar.right.pane.tab', FILES_ID, 'sidebarFiles', FilesBody], + ['sidebar.right.pane.tab.title', FILES_ID, undefined, FilesTitle], ]) expect(registered[0]?.store).toBeDefined() expect(typeof registered[0]?.inject).toBe('function') diff --git a/packages/client/ui-sidebar-files/tests/definition.client.spec.ts b/packages/client/ui-sidebar-files/tests/definition.client.spec.ts index dc5dfeda1b..ad88a03c44 100644 --- a/packages/client/ui-sidebar-files/tests/definition.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/definition.client.spec.ts @@ -31,7 +31,6 @@ describe('filesDefinition', () => { expect(entry?.order).toBe(10) expect(entry?.kind).toBe(FILES_KIND) expect(entry?.title()).toBe(zh['guide.title']) - expect(entry?.description()).toBe(zh['guide.description']) expect(entry?.icon).toBeDefined() }) diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index cf889e3859..d9c8309481 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -9,7 +9,7 @@ * was cut or could not be read, and reload asks again for the expanded levels * only. The two pure helpers the rows are built from are checked on their own. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent } from '@testing-library/react' import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' @@ -43,23 +43,75 @@ describe('FilesBody', () => { expect(script.list).not.toHaveBeenCalled() }) - it('lists the root on mount, names it by its basename, and draws directories first with dotfiles kept', async () => { + it('lists the root on mount, heads it with its path split at the last segment, and draws directories first with dotfiles kept', async () => { const { view, script } = mountBody() expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, expect.any(AbortSignal)) expect(view.container.querySelector('[data-files-row="loading"]')).not.toBeNull() await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) expect(view.container.querySelector('[data-files-state="tree"]')?.getAttribute('data-files-root')).toBe(ROOT) - expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('app') + const path = view.container.querySelector('[data-files-path]') + expect(path?.getAttribute('title')).toBe(ROOT) + expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/work/', 'app']) expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`]) }) - it('labels a separator-only root by the root itself, since it has no final segment', async () => { + it('heads a separator-only root by the root itself, since it has no final segment', async () => { const { view, script } = mountBody('/') await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('/') + const path = view.container.querySelector('[data-files-path]') + expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/']) expect(names(view.container)).toEqual(['/src', '/.env', '/pipe', '/README.md']) }) + it('marks the root path clipped while its text is wider than its box, re-reading on resize', async () => { + class FakeResizeObserver implements ResizeObserver { + static latest: FakeResizeObserver | undefined + readonly observe = vi.fn() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.latest = this + } + + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + let boxWidth = 300 + const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth') + const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth }) + try { + const { view, script } = mountBody() + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + const path = view.container.querySelector('[data-files-path]') + const text = path?.firstElementChild + expect(path?.hasAttribute('data-files-path-clipped')).toBe(false) + const observer = FakeResizeObserver.latest + if (observer === undefined) throw new Error('expected the path to observe its size') + expect(observer.observe).toHaveBeenCalledWith(path) + expect(observer.observe).toHaveBeenCalledWith(text) + + boxWidth = 120 + act(() => { observer.fire() }) + expect(path?.hasAttribute('data-files-path-clipped')).toBe(true) + + boxWidth = 300 + act(() => { observer.fire() }) + expect(path?.hasAttribute('data-files-path-clipped')).toBe(false) + view.unmount() + expect(observer.disconnect).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) { + if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name) + else Object.defineProperty(HTMLElement.prototype, name, descriptor) + } + } + }) + it('a directory click lists that level once and marks it expanded; a second click collapses without asking again', async () => { const { view, script } = mountBody() await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) diff --git a/packages/client/ui-sidebar-files/tests/files-title.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-title.client.spec.tsx new file mode 100644 index 0000000000..14d76dcbc8 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/files-title.client.spec.tsx @@ -0,0 +1,22 @@ +// @vitest-environment jsdom +/** The chip title: the folder sheet, then the type's label. */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { FilesTitle } from '../src/client/FilesTitle.tsx' + +afterEach(cleanup) + +function props(title: string): PropsRuntime<'sidebar.right.pane.tab.title'> { + return { useTabInfo: () => ({ tab: { title } }) } as unknown as PropsRuntime<'sidebar.right.pane.tab.title'> +} + +describe('FilesTitle', () => { + it('draws the folder sheet before the title text, sized to the chip line', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg?.getAttribute('width')).toBe('16') + expect(svg?.getAttribute('aria-hidden')).toBe('true') + expect(container.textContent).toBe('文件') + }) +}) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index e235b63d32..b8a821215f 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 993f3772f2b4b24ea7fed6a58d6614f1bc445cc0 -README.zh.md: 2907f202be7872a56abfe9052109b8d781e31c6a +README.md: 128f0e41a1b1a90f39f4db36f1a8ca8dfa3089f7 +README.zh.md: 66f2e8d6a07635d7e94ee50395c274951911e517 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 993f3772f2..128f0e41a1 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -50,7 +50,7 @@ The panel has no header row. Its two controls — the presentation switch and th ## The expand button -While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders a same-size placeholder, so the corner keeps its width and nothing in the header row moves. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel. +While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders nothing, and the corner seat collapses with it. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel. The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. @@ -65,6 +65,8 @@ Carrying the mint counter in the surface is what makes a recorded sequence repla After every action the kit's settle planner keeps the surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. Explicit closing follows [the default-page and close rules](#the-guide). There is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture. +The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the settle planner reseeds the current default page for the next expansion. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely. + State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was. @@ -94,9 +96,9 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi ## The guide -Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Each single-entry default remains non-closable when other tabs are added. Any pane's last tab is also non-closable; other tabs can close. The chip, context menu, and `close` API apply the same rule. Explicitly adding a guide still opens the guide, even with one entry. +Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. -The guide tab is a centred title, one line under it, and one entry box per `guide` entry the registered types contributed, in `order`. Picking a box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. +The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. ## Copy @@ -120,7 +122,6 @@ None; this package neither assembles nor sends a provider request. - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right. - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet. - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent. -- **Guide copy is a draft** awaiting product review; the words live in `locales.ts`. - **Titles are fixed at open time.** A type's `title(address)` is captured into the record; a live title comes only from the optional title seat. - **No content navigation stack.** Stepping back replays layout operations; an editor-style back/forward over visited content is not built. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 2907f202be..66f2e8d6a0 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ## 展开按钮 -面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它渲染一个同尺寸的占位,因此角落保持宽度,header 行里没有东西会移动。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。 +面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它什么也不渲染,角落席位随之收起。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 @@ -65,6 +65,8 @@ kind: "package-reference" 每个动作之后,套件的 settle planner 保证停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。 +停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史——settle planner 为下次展开重新播种当前默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。 + 状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。 @@ -94,9 +96,9 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私 ## 引导页 -默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。单入口生成的默认页在增加其他 tab 后仍不可关闭。任何格的最后一个 tab 也不可关闭;其他 tab 可以关闭。chip、上下文菜单与 `close` API 使用同一规则。显式添加引导页始终打开引导,即使只有一个入口。 +默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 -引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。 达到两格上限时隐藏分栏控件;关闭回单格后恢复。 +引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 ## 文案 @@ -120,7 +122,6 @@ None; this package neither assembles nor sends a provider request. - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。 -- **引导页文案是草稿**,等待产品评审;文字住在 `locales.ts`。 - **标题在打开时固定。** 类型的 `title(address)` 被捕获进记录;会变的标题只来自可选的标题席位。 - **没有内容导航栈。** 后退回放的是布局操作;编辑器式的「已访问内容」前进/后退尚未构建。 diff --git a/packages/client/ui-sidebar-right/src/client/contract/seed.ts b/packages/client/ui-sidebar-right/src/client/contract/seed.ts index 07578f071a..ce5adc1f98 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/seed.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/seed.ts @@ -16,7 +16,6 @@ import type { SidebarRightTabRegistry } from '../tab-registry.ts' export interface SidebarRightSeed { readonly kind: string readonly title: string - readonly permanent: boolean } /** @@ -30,7 +29,7 @@ export function defaultSeed(tabs: SidebarRightTabRegistry): SidebarRightSeed { const kind = single ? only.kind : GUIDE_KIND const definition = tabs.get(kind) if (definition === undefined) throw new Error(`sidebarRight: default tab kind "${kind}" is not registered`) - return { kind, title: definition.title(pageAddress(kind)), permanent: single } + return { kind, title: definition.title(pageAddress(kind)) } } /** The guide tab's kind. */ diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 94ba94c17f..6e2df4fe40 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -30,6 +30,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from './contract/slots.ts' import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' +import { GuideTitle } from './tabs/guide/GuideTitle.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' import { RightbarRoot } from './shell/RightbarRoot.tsx' @@ -183,7 +184,6 @@ export function apply(ctx: ClientContext): void { const disposeGuide = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: GUIDE_ID, - locale: NS, children: { 'sidebar.right.tab.guide': { kind: 'chain', scope: 'session', inject: { hooks: { tabInfo: guideTabInfoFactory } }, @@ -191,7 +191,12 @@ export function apply(ctx: ClientContext): void { }, inject: () => guideInjected, }, GuideBody)) + const disposeGuideTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab.title', key: GUIDE_ID }, + GuideTitle, + )) return () => { + disposeGuideTitle() disposeGuide() disposeExpand() disposeSeat() diff --git a/packages/client/ui-sidebar-right/src/client/labels.ts b/packages/client/ui-sidebar-right/src/client/labels.ts index d259eecca7..cf39eb9d46 100644 --- a/packages/client/ui-sidebar-right/src/client/labels.ts +++ b/packages/client/ui-sidebar-right/src/client/labels.ts @@ -26,5 +26,12 @@ export function dockLabels(t: TranslateNS<'sidebarRight'>): DockLabels { addTab: t('dock.addTab'), dockFloat: t('dock.dockFloat'), closeFloat: t('dock.closeFloat'), + dropZone: { + center: t('dock.drop.center'), + left: t('dock.drop.left'), + right: t('dock.drop.right'), + top: t('dock.drop.top'), + bottom: t('dock.drop.bottom'), + }, } } diff --git a/packages/client/ui-sidebar-right/src/client/locales.ts b/packages/client/ui-sidebar-right/src/client/locales.ts index 3e5b697967..7f411c8519 100644 --- a/packages/client/ui-sidebar-right/src/client/locales.ts +++ b/packages/client/ui-sidebar-right/src/client/locales.ts @@ -8,22 +8,27 @@ /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { - 'chrome.expand': '展开侧栏', - 'chrome.collapse': '收起侧栏', - 'chrome.toFullscreen': '全屏显示侧栏', - 'chrome.exitFullscreen': '退出侧栏全屏', + 'chrome.expand': '打开侧边栏', + 'chrome.expandAria': '打开右侧边栏', + 'chrome.collapse': '收起侧边栏', + 'chrome.collapseAria': '收起右侧边栏', + 'chrome.toFullscreen': '全屏', + 'chrome.exitFullscreen': '退出全屏', 'dock.emptyPane': '空面板', - 'dock.splitPane': '向右分栏', + 'dock.splitPane': '分栏', 'dock.splitPaneDisabled': '已达两格上限', - 'dock.splitPaneNarrow': '栏宽不足,拖宽侧栏后再分栏', + 'dock.splitPaneNarrow': '栏宽不足,拖宽侧边栏后再分栏', 'dock.closeTab': '关闭', 'dock.addTab': '新标签页', - 'dock.dockFloat': '收回到侧栏', + 'dock.dockFloat': '收回到侧边栏', 'dock.closeFloat': '关闭', + 'dock.drop.center': '移到这里', + 'dock.drop.left': '左分栏', + 'dock.drop.right': '右分栏', + 'dock.drop.top': '上分栏', + 'dock.drop.bottom': '下分栏', 'tab.guide.title': '开始', 'tab.unavailable': '这类内容还没有可用的查看方式。', - 'guide.lead': '侧栏用来放你想一直看着的东西。', - 'guide.body': '会话里的文件和产物会开在这一栏,也可以从下面的入口打开。', } satisfies Record /** Right-Sidebar dictionary key union. */ @@ -31,20 +36,25 @@ export type SidebarRightKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { - 'chrome.expand': 'Open the sidebar', - 'chrome.collapse': 'Close the sidebar', - 'chrome.toFullscreen': 'Show the sidebar fullscreen', - 'chrome.exitFullscreen': 'Exit sidebar fullscreen', + 'chrome.expand': 'Open sidebar', + 'chrome.expandAria': 'Open right sidebar', + 'chrome.collapse': 'Collapse sidebar', + 'chrome.collapseAria': 'Collapse right sidebar', + 'chrome.toFullscreen': 'Fullscreen', + 'chrome.exitFullscreen': 'Exit fullscreen', 'dock.emptyPane': 'Empty pane', - 'dock.splitPane': 'Split to the right', + 'dock.splitPane': 'Split', 'dock.splitPaneDisabled': 'Two panes is the limit', - 'dock.splitPaneNarrow': 'Not enough width to split; widen the sidebar', + 'dock.splitPaneNarrow': 'Not enough width to split, widen the sidebar', 'dock.closeTab': 'Close', 'dock.addTab': 'New tab', 'dock.dockFloat': 'Send back to the sidebar', 'dock.closeFloat': 'Close', + 'dock.drop.center': 'Move here', + 'dock.drop.left': 'Add left split', + 'dock.drop.right': 'Add right split', + 'dock.drop.top': 'Add top split', + 'dock.drop.bottom': 'Add bottom split', 'tab.guide.title': 'Start', 'tab.unavailable': 'Nothing here can view this kind of content yet.', - 'guide.lead': 'The sidebar holds what you want to keep looking at.', - 'guide.body': 'Files and artifacts from the conversation open in this column; the entries below open more.', } satisfies Record diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 768fc33364..81e003b06a 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -157,7 +157,7 @@ export interface ISidebarRight { */ openTab(kind: K, options?: SidebarRightOpenTabOptions): void /** - * Close one tab of the mounted session; permanent defaults and a pane's last tab remain open. + * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void @@ -286,7 +286,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Close a tab of one session, preserving permanent defaults and a pane's last tab; nothing happens + * Close a tab of one session, preserving the sole docked guide; nothing happens * for a session whose store was never adopted or whose adoption was released. * Not part of `ISidebarRight`: the Tab domain's path. * @param sessionId - the session the tab is in. @@ -343,7 +343,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Close one tab of the mounted session; permanent defaults and a pane's last tab remain open. + * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void { diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css index d13872a336..886a826567 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css @@ -1,34 +1,29 @@ /* * Sits in the conversation header's corner seat, level with the Session log - * control, so it takes that row's 32px height and the same hover fill; square - * with the header's corner radius rather than a pill, because it is an icon - * button, not a labelled one. The placeholder is the same box with nothing in - * it, so the seat's width holds while the panel is shown. + * control. The same icon button as the panel's own strip controls: a 28px + * circle around a 15px glyph. */ -.placeholder { - display: inline-block; - flex: none; - width: 32px; - height: 32px; -} - .button { display: inline-flex; flex: none; align-items: center; justify-content: center; - width: 32px; - height: 32px; - padding: 0; + width: 28px; + height: 28px; + padding: 6px; color: var(--dsw-alias-label-secondary); background: transparent; border: none; - border-radius: 8px; + border-radius: 28px; cursor: pointer; } +.button svg { + width: 15px; + height: 15px; +} + .button:hover { - color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx index a106bba675..7d9b2d276e 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx @@ -5,17 +5,16 @@ * It lives in the conversation's own header rather than in the frame's right * column so that a collapsed Sidebar costs the conversation nothing — no rail, * no width, and the transcript's scrollbar stays at the column's edge. The - * corner seat is its own, past the utilities' edge, so the button neither joins - * the utilities row nor moves it: while the panel is shown this renders a - * same-size placeholder, and the seat's width stays reserved. It shares the - * panel's per-session store, which the slot runtime allows because both seats - * are session-scoped. + * corner seat is its own, past the utilities' edge, so the button never joins + * the utilities row; while the panel is shown this renders nothing, and the + * seat collapses with it. It shares the panel's per-session store, which the + * slot runtime allows because both seats are session-scoped. * * The glyph is the left sidebar's collapse icon mirrored: the same affordance, * on the other edge. */ import type { ReactNode } from 'react' -import { IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { createSidebarRightStore } from '../stores.ts' @@ -27,22 +26,23 @@ export type ExpandButtonProps = & PropsStore> & PropsLocale<'sidebarRight'> -/** The expand control while the panel is collapsed; its footprint while it is shown. */ +/** The expand control while the panel is collapsed; nothing while it is shown. */ export function ExpandButton({ sessionId, useStore, actions, t }: ExpandButtonProps): ReactNode { // A session with no surface yet is collapsed: the panel seat materializes the // surface on its own mount, and until then there is nothing expanded. const expanded = useStore(state => state.bySession[sessionId]?.layout.expanded ?? false) - if (expanded) return + if (expanded) return null return ( - + + + ) } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index 1940785098..890d58ac89 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -29,9 +29,10 @@ flex-direction: column; min-width: 0; /* The same ground as the conversation: this is a column of the page, not a - raised surface. */ + raised surface. The edge is the weight of the kit's split dividers inside + (ui-dockkit `.divider`), so the panel's outline and its inner rules match. */ background: var(--dsw-alias-bg-base); - border-left: 0.5px solid var(--dsw-alias-border-l1); + border-left: 0.5px solid var(--dsw-alias-border-l4); transform: translateX(100%); visibility: hidden; transition: @@ -59,28 +60,38 @@ } } +/* The geometry of a message's action row button (ui-chat MessageIconActions), + shared with the kit's split control beside it: a 28px circle around a 15px glyph. */ .iconButton { - display: flex; + display: inline-flex; flex: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; - padding: 0; + width: 28px; + height: 28px; + padding: 6px; color: var(--dsw-alias-label-secondary); - font-size: 14px; line-height: 1; background: transparent; border: none; - border-radius: 4px; + border-radius: 28px; cursor: pointer; } +.iconButton svg { + width: 15px; + height: 15px; +} + .iconButton:hover { - color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +/* The expand button's glyph, so collapse and expand share one affordance. */ +.collapseGlyph { + transform: scaleX(-1); +} + .panelBody { display: flex; flex: 1 1 auto; @@ -89,6 +100,7 @@ .unavailable { margin: 0; + padding: 12px; color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index f555e7b9f0..355eedc190 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -30,6 +30,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { ReactNode, RefObject } from 'react' import { createPortal } from 'react-dom' +import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' @@ -221,29 +222,28 @@ function titlesFor(panel: PanelProps): TabRenderer { return tab => } -/** Expand-to-viewport glyph. */ +/** Expand-to-viewport glyph: four frame corners (figma extract). */ function FullscreenGlyph(): ReactNode { return ( - } - - {entry.title()} - {entry.description()} - + {entry.title()} ) } -/** The shipped guide: what the column is for, and the doors out of it. */ -function ShippedGuide({ entries, onPick, t }: { +/** The shipped guide: the doors out of the column. */ +function ShippedGuide({ entries, onPick }: { entries: readonly SidebarRightGuideBox[] onPick: (entry: SidebarRightGuideBox) => void - t: GuideBodyProps['t'] }): ReactNode { return (
-

{t('guide.lead')}

-

{t('guide.body')}

- {entries.length > 0 && ( -
- {/* Keyed by position in the ordered list: one type may contribute several boxes, and `order` is not unique. */} - {entries.map((entry, index) => )} -
- )} + {/* Keyed by position in the ordered list: one type may contribute several capsules, and `order` is not unique. */} + {entries.map((entry, index) => )}
) } /** The guide tab's body, replaceable through its chain child. */ -export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain, t }: GuideBodyProps): ReactNode { +export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: GuideBodyProps): ReactNode { const { tab } = useTabInfo() const entries = useGuideEntries(entries => entries) const options = { hookContext: useTabInfo, fallback: ( - { tab.actions.openTab(entry.kind, { replaceTab: true }) }} t={t} /> + { tab.actions.openTab(entry.kind, { replaceTab: true }) }} /> ), } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> } return renderSlotChain('sidebar.right.tab.guide', {}, options) diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx new file mode 100644 index 0000000000..2b8428b789 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx @@ -0,0 +1,35 @@ +/** + * The guide type's chip title: the compass before the type's label. Registered + * under `sidebar.right.pane.tab.title`; without it the chip would show the + * bare label. + */ +import type { ReactNode } from 'react' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './GuideBody.module.css' + +/** + * The compass: a grey ring with the needle's rhombus pointing north-east. + */ +function CompassGlyph(): ReactNode { + return ( + + ) +} + +/** + * The title as the chip and a floating panel's header show it. + * @param props - the tab information hook. + * @returns the compass followed by the tab's title text. + */ +export function GuideTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode { + const { tab } = useTabInfo() + return ( + <> + + {tab.title} + + ) +} diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 78917534ed..543a18f666 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -22,6 +22,7 @@ import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx' import { RightbarRoot } from '../src/client/shell/RightbarRoot.tsx' import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' +import { GuideTitle } from '../src/client/tabs/guide/GuideTitle.tsx' import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts' import { en, zh } from '../src/client/locales.ts' @@ -92,11 +93,15 @@ describe('ui-sidebar-right apply', () => { expect(guide?.id).toBe(GUIDE_ID) expect(guide?.priority).toBe('builtin') expect(guide?.title('sidebar://guide')).toBe('tab.guide.title') + // Five registrations: the root and panel seats, the header's corner seat, + // and the guide body and chip title under the guide implementation's id. + // The guide binds no dictionary: its only words are the entries' own. expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ ['rightbar', undefined, undefined, RightbarRoot], ['rightbar.session', undefined, 'sidebarRight', RightbarSeat], ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton], - ['sidebar.right.pane.tab', GUIDE_ID, 'sidebarRight', GuideBody], + ['sidebar.right.pane.tab', GUIDE_ID, undefined, GuideBody], + ['sidebar.right.pane.tab.title', GUIDE_ID, undefined, GuideTitle], ]) // The panel declares the extension seats; the guide declares its chain child. expect(Object.keys(seat('rightbar.session').children as object)).toEqual([ @@ -151,16 +156,14 @@ describe('ui-sidebar-right apply', () => { expect(seat('conversation.session.header.corner').store).toBe(handle) const instance = handle.create(SESSION) instance.actions.open(SESSION) - const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {})[0] + // A second tab beside the guide makes the guide closable. + instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s/a.txt', title: 'a' }, () => {}) + const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {}).find(tab => tab.kind === 'guide') if (guide === undefined) throw new Error('expected the seeded guide') // Held and pinned from the store's own commit: no seat synced anything. const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal) occurrence.tabActions.close() - expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBe(guide) - expect(occurrence.signal.aborted).toBe(false) - instance.actions.openContent(SESSION, { kind: 'text', contentId: 'file:test', title: 'test' }, () => {}) - occurrence.tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined() expect(occurrence.signal.aborted).toBe(true) }) @@ -175,7 +178,7 @@ describe('ui-sidebar-right apply', () => { id: 'spec/files', kind: 'files', title: () => 'Files', - guide: [{ order: 10, title: () => 'Files', description: () => 'The workspace tree' }], + guide: [{ order: 10, title: () => 'Files' }], }) expect(seen).toHaveBeenCalledOnce() expect(guideEntries.getSnapshot().map(entry => entry.kind)).toEqual(['files']) @@ -204,6 +207,6 @@ describe('ui-sidebar-right apply', () => { expect(dictionaries.size).toBe(0) await ctx.plugin({ inject: [...inject], apply }).await() expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID) - expect(registered).toHaveLength(4) + expect(registered).toHaveLength(5) }) }) diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx index a48a2f2933..4df1913591 100644 --- a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom /** * The way back into a hidden panel: the header's corner button exists exactly - * while the panel is collapsed, asks for it to expand, and leaves a same-size - * footprint while the panel is shown so the header row never moves. + * while the panel is collapsed, asks for it to expand, and renders nothing + * while the panel is shown. */ import { describe, expect, it } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' @@ -27,7 +27,7 @@ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapsho * documented cast keeps the harness to what is actually exercised. */ function mountButton() { - const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create() const props = { sessionId: SESSION, useStore: hookOf(instance), @@ -37,32 +37,28 @@ function mountButton() { } as unknown as ExpandButtonProps const view = render() const control = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand]') - const placeholder = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand-placeholder]') - return { instance, view, control, placeholder } + return { instance, view, control } } describe('ExpandButton', () => { it('offers the way in while the session has no surface yet, and asks the panel to expand', () => { - const { instance, control, placeholder } = mountButton() + const { instance, view, control } = mountButton() const button = control() if (button === null) throw new Error('expected the expand control') - expect(button.getAttribute('aria-label')).toBe('chrome.expand') - expect(placeholder()).toBeNull() + expect(button.getAttribute('aria-label')).toBe('chrome.expandAria') fireEvent.click(button) expect(instance.getSnapshot().bySession[SESSION]?.layout.expanded).toBe(true) - // Shown: the control gives way to its footprint, so the seat keeps its width. - expect(control()).toBeNull() - expect(placeholder()).not.toBeNull() + // Shown: the seat is empty, so the header lays out without it. + expect(view.container.childElementCount).toBe(0) cleanup() }) it('comes back when the panel collapses again', () => { - const { instance, control, placeholder } = mountButton() + const { instance, control } = mountButton() act(() => { instance.actions.setExpanded(SESSION, true) }) expect(control()).toBeNull() act(() => { instance.actions.setExpanded(SESSION, false) }) expect(control()).not.toBeNull() - expect(placeholder()).toBeNull() cleanup() }) }) diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index 3e25bc1b4a..09955a10b5 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -2,7 +2,7 @@ /** * The guide tab's body: the chain seam, and the shipped guide behind it. * - * The contract a type relies on is the entry box: one per guide entry every + * The contract a type relies on is the entry capsule: one per guide entry every * registered type contributed, in the registry's order, and picking one opens * that type as a page in the guide's own tab. The chain is asserted through * what the body hands it — the tab and the shipped guide as the fallback. @@ -26,13 +26,12 @@ function Glyph({ size }: IconProps): ReactNode { return } -/** One entry box as the registry lists it. */ +/** One entry capsule as the registry lists it. */ function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon']): SidebarRightGuideBox { return { kind, order, title: () => `${kind} title`, - description: () => `${kind} description`, ...icon === undefined ? {} : { icon }, } } @@ -49,8 +48,6 @@ function mountGuide(entries: readonly SidebarRightGuideBox[]) { useTabInfo: () => ({ tab: { ...TAB, actions: { openResource: vi.fn(), openTab, close: vi.fn() } } }), useGuideEntries: bindSnapshotSelector(guideEntries), renderSlotChain: renderSlot, - // Copy is the dictionary's contract; the key stands in for the translation. - t: (key: string) => key, } as unknown as GuideBodyProps const view = render() const boxes = (): string[] => @@ -64,13 +61,13 @@ describe('GuideBody', () => { expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.guide', {}, { hookContext: useTabInfo, fallback: expect.anything() as ReactNode, }) + // The guide says nothing of its own: its words are the capsules'. const guide = view.container.querySelector('[data-sidebar-right-guide]') - expect(guide?.textContent).toContain('guide.lead') - expect(guide?.textContent).toContain('guide.body') - // One box per entry, in the registry's order, each with its own words; only the first brought a glyph. + expect(guide?.textContent).toBe('files titleterminal title') + // One capsule per entry, in the registry's order, each with its own title; only the first brought a glyph. expect(boxes()).toEqual(['files', 'terminal']) const [files, terminal] = [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')] - expect(files?.textContent).toBe('files titlefiles description') + expect(files?.textContent).toBe('files title') expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('16') expect(terminal?.querySelector('[data-guide-glyph]')).toBeNull() cleanup() @@ -85,7 +82,7 @@ describe('GuideBody', () => { cleanup() }) - it('draws the words alone while no type contributed an entry, and follows the registry when one does', () => { + it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => { const { view, guideEntries, boxes } = mountGuide([]) expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull() expect(boxes()).toEqual([]) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index daa09d5ec3..22c8527b8b 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -130,38 +130,44 @@ describe('RightbarSeat presentation', () => { expect(h.layout()).toBe(retained) }) - it.each([0, 1, 2])('selects the default from %i guide entries and protects a single tab', async (entryCount) => { + it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => { const h = await mountSeat(1440, true, entryCount) act(() => { h.controller.toggleExpanded() }) const initial = Object.values(h.layout().tabs)[0]! expect(initial.kind).toBe(entryCount === 1 ? 'text' : 'guide') - expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) - const before = h.layout() - act(() => { h.controller.close(initial.id) }) - expect(h.layout()).toBe(before) - fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]')) - expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull() if (entryCount !== 1) { + expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) + const before = h.layout() + act(() => { h.controller.close(initial.id) }) + expect(h.layout()).toBe(before) + fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]')) + expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull() expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() return } + expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull() + act(() => { h.controller.close(initial.id) }) + expect(h.layout().expanded).toBe(false) + const reseeded = Object.values(h.layout().tabs)[0]! + expect(reseeded.kind).toBe('text') + expect(reseeded.id).not.toBe(initial.id) + act(() => { h.controller.toggleExpanded() }) fireEvent.click(element(h.view.container, '[data-dockkit-add-tab]')) const guide = Object.values(h.layout().tabs).find(tab => tab.kind === 'guide')! expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() - expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).toBeNull() + expect(h.view.container.querySelector(`[data-dockkit-tab-close="${reseeded.id}"]`)).not.toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull() - act(() => { h.controller.close(initial.id) }) - expect(h.layout().tabs[initial.id]).toBe(initial) + act(() => { h.controller.close(reseeded.id) }) + expect(h.layout().tabs[reseeded.id]).toBeUndefined() + expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).toBeNull() const preview = h.open('ordinary.txt') expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull() act(() => { h.controller.close(preview.id) }) expect(h.layout().tabs[preview.id]).toBeUndefined() - fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${guide.id}"]`)) - expect(Object.keys(h.layout().tabs)).toEqual([initial.id]) + expect(Object.keys(h.layout().tabs)).toEqual([guide.id]) expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) - expect(h.view.container.querySelector('[data-dockkit-add-tab]')).not.toBeNull() act(() => { h.controller.split() }) - expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['text', 'text']) + expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['guide', 'text']) }) it('offers close for a floating tab while the docked pane keeps its sole tab', async () => { @@ -449,7 +455,7 @@ describe('slot-owned useTabInfo', () => { await act(async () => { h.runtime.ctx.sidebarRightTabs.register({ id: 'test/files', kind: 'files', title: () => 'Files', - guide: [{ order: 1, title: () => 'Files', description: () => 'Browse' }], + guide: [{ order: 1, title: () => 'Files' }], }) }) expect(h.view.container.querySelector('[data-sidebar-right-guide-entry="files"]')).not.toBeNull() @@ -524,10 +530,6 @@ describe('slot-owned useTabInfo', () => { expect(getPane(h.layout(), right).tabs.filter(id => h.layout().tabs[id]?.kind === 'guide')).toHaveLength(1) const closing = [...getPane(h.layout(), right).tabs] act(() => { for (const tabId of closing) h.actions.closeTab(SESSION, tabId) }) - expect(dockPaneIds(h.layout())).toHaveLength(2) - const remaining = getPane(h.layout(), right).tabs[0]! - expect(h.view.container.querySelector(`[data-dockkit-tab-close="${remaining}"]`)).toBeNull() - act(() => { h.actions.placeTab(SESSION, remaining, dockPaneIds(h.layout())[0]!, 0) }) expect(dockPaneIds(h.layout())).toHaveLength(1) expect(splitButtons()).toHaveLength(1) expect(splitButtons()[0]?.disabled).toBe(false) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 10d6b96745..d5f1ec099d 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -48,7 +48,7 @@ function harness() { }) const pin = vi.fn<(address: string, signal: AbortSignal) => void>() const { controller, adopt } = createSidebarRightController(tabs, pin) - const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create() const layout = (): LayoutState => { const surface = instance.getSnapshot().bySession[SESSION] if (surface === undefined) throw new Error('expected a surface') @@ -445,7 +445,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide) // The user switches sessions: the other seat binds with the other session's // own instance, whose store knows nothing of this session. - const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create(OTHER) + const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) const releaseOther = adopt(OTHER, other) other.actions.open(OTHER) const otherSurface = other.getSnapshot().bySession[OTHER] @@ -519,7 +519,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { // Adopting another instance for the session ends the earlier adoption's // subscription with its routing: the old store's commits sync nothing, the // new store's commits reconcile the session against its own layout. - const replacement = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create() + const replacement = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create() const third = adopt(SESSION, replacement) const pins = pin.mock.calls.length instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/c.txt', title: 'c' }, () => {}) diff --git a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts index 786bd30e03..a42f0a295c 100644 --- a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts @@ -15,7 +15,7 @@ import { createSidebarRightStore } from '../src/client/stores.ts' const SESSION = 's-test' function harness() { - const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create() instance.actions.open(SESSION) const surface = () => { const held = instance.getSnapshot().bySession[SESSION] @@ -136,6 +136,65 @@ describe('createSidebarRightStore — the sequence', () => { }) }) +describe('createSidebarRightStore — the last docked tab', () => { + it('refuses to close the guide standing as the docked surface\'s only tab, recording nothing', () => { + const { actions, surface, layout, entries, guide } = harness() + actions.setExpanded(SESSION, true) + const before = surface() + const recorded = entries() + actions.closeTab(SESSION, guide()) + expect(surface()).toBe(before) + expect(entries()).toBe(recorded) + expect(layout().tabs[guide()]).toBeDefined() + }) + + it('closes the last non-guide tab together with the column, reseeding the guide, as one reversible entry', () => { + const { actions, layout, entries } = harness() + actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {}) + const text = Object.values(layout().tabs).find(tab => tab.kind === 'text') + const seeded = Object.values(layout().tabs).find(tab => tab.kind !== 'text') + if (text === undefined || seeded === undefined) throw new Error('expected the text tab beside the guide') + // Two tabs: closing the guide is an ordinary close, and the column stays open. + actions.closeTab(SESSION, seeded.id) + expect(layout().expanded).toBe(true) + expect(layout().tabs[seeded.id]).toBeUndefined() + const before = layout() + const recorded = entries() + // Fullscreen at the moment of the close: the collapse must hand the mode + // back too, or the next expand gives the whole window to the default page. + actions.setMode(SESSION, 'fullscreen') + + actions.closeTab(SESSION, text.id) + + expect(layout().expanded).toBe(false) + expect(layout().mode).toBe('push') + expect(layout().tabs[text.id]).toBeUndefined() + // The settle rule reseeded the emptied root pane, so reopening shows the guide. + const reseeded = Object.values(layout().tabs) + expect(reseeded).toHaveLength(1) + expect(entries()).toBe(recorded + 2) + + actions.undo(SESSION) + actions.undo(SESSION) + expect(layout()).toEqual(before) + }) + + it('closes a floating tab without touching the column: floats do not count as the last docked tab', () => { + const { actions, layout, guide } = harness() + actions.setExpanded(SESSION, true) + actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {}) + const text = Object.values(layout().tabs).find(tab => tab.kind === 'text') + if (text === undefined) throw new Error('expected the text tab') + actions.floatTab(SESSION, text.id, { x: 10, y: 20, width: 300, height: 200 }) + // The guide is now the docked surface's only tab; the floating text tab closes freely. + actions.closeTab(SESSION, text.id) + expect(layout().floats).toHaveLength(0) + expect(layout().tabs[text.id]).toBeUndefined() + expect(layout().expanded).toBe(true) + expect(layout().tabs[guide()]).toBeDefined() + }) +}) + describe('createSidebarRightStore — floating panels and dividers', () => { it('closes the sole tab in a floating pane while retaining the docked pane', () => { const { actions, layout, entries, guide } = harness() diff --git a/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx index 8acb213762..3f36395704 100644 --- a/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx @@ -21,7 +21,7 @@ afterEach(() => { }) function harness() { - const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create() const domain = new TabDomain({ openResourceIn: vi.fn(), openTabIn: vi.fn(), closeIn: vi.fn() }, vi.fn()) domains.push(domain) const navigationSources = new Map() diff --git a/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts index 905e6a4187..3cfca82d79 100644 --- a/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts @@ -210,7 +210,7 @@ describe('SidebarRightTabRegistry — ids and page types', () => { kind: 'files', priority: 'builtin', title: () => 'Files', - guide: [{ order: 10, title: () => 'Files', description: () => 'Browse' }], + guide: [{ order: 10, title: () => 'Files' }], }) expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual([]) expect(registry.get('files')?.title('x')).toBe('Files') @@ -258,7 +258,7 @@ describe('SidebarRightTabRegistry — lifetime', () => { it('collects every type\'s guide entries in order, reference-stable between changes', () => { const registry = new SidebarRightTabRegistry(new Context()) - const entry = (order: number) => ({ order, title: () => `#${order}`, description: () => '' }) + const entry = (order: number) => ({ order, title: () => `#${order}` }) registry.register(typeFor('files', [], { guide: [entry(10)] })) const first = registry.guide() expect(registry.guide()).toBe(first) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4560faa346..7dc2ec6c6c 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -387,7 +387,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:158', }, { key: 'conversation.composer.bar', @@ -425,7 +425,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176', }, { key: 'conversation.composer.dock', @@ -480,7 +480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:170', }, { key: 'conversation.hero.agentPreset', @@ -510,7 +510,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:164', }, { key: 'conversation.hero.brand.mark', @@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162', }, { key: 'conversation.hero.workspace', @@ -570,7 +570,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:160', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -641,7 +641,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', }, { key: 'conversation.input.dock', @@ -703,7 +703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:166', }, { key: 'conversation.input.left', @@ -756,7 +756,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172', }, { key: 'conversation.input.model', @@ -794,7 +794,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186', }, { key: 'conversation.input.overlay', @@ -850,7 +850,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168', }, { key: 'conversation.input.plan', @@ -888,7 +888,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184', }, { key: 'conversation.input.right', @@ -941,7 +941,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174', }, { key: 'conversation.message.images', @@ -1121,8 +1121,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'conversation.session.header.corner', kind: 'single', scope: 'session', - summary: 'The header\'s far-right corner, past the utilities\' edge and into the header\'s own padding, for one control that must keep its place whether or not it currently shows anything.', - doc: 'The header\'s far-right corner, past the utilities\' edge and into the\nheader\'s own padding, for one control that must keep its place whether or\nnot it currently shows anything. The corner reserves its width while an\noccupant is registered, so the utilities beside it never move; an\noccupant with nothing to show renders a same-size placeholder.', + summary: 'The header\'s far-right corner, past the utilities\' edge and into the header\'s own padding, for one control.', + doc: 'The header\'s far-right corner, past the utilities\' edge and into the\nheader\'s own padding, for one control. The corner is laid out only while\nits occupant renders something; an occupant with nothing to show renders\nnothing, and the utilities take the header\'s edge.', registerOptions: [], ownerProps: [ '/** The header corner\'s occupant derives its state from standard Session props. */\nexport interface ConversationHeaderCornerOwnerProps {\n /** Marker field: the occupant receives no owner-specific values. */\n children?: never\n}', @@ -1153,7 +1153,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150', }, { key: 'conversation.session.header.lineage', @@ -1353,7 +1353,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:156', }, { key: 'main', @@ -2303,7 +2303,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ hookContext: 'TabHookContext', slotInject: 'SidebarRightTabInjected', declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', - occupants: [], + occupants: [ + 'client-ui-sidebar-documentpreview TextTitle', + 'client-ui-sidebar-files FilesTitle', + 'client-ui-sidebar-right GuideTitle', + ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:64', diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index d6e597b312..77300b7fc3 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md -README.md: db26d9c405b3408cc24f27e5947c94ccaa2393e8 -README.zh.md: 4e2eb00de94dd6520af8c1836155a00d187ef99f +README.md: 97e44c33ec09f96ec21f76b06c1928fd1de6fb0d +README.zh.md: 706a8fcd13cea8e0fbd5984238164f82a98913f5 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index db26d9c405..97e44c33ec 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-log-export` lets the Web interface download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow. +`dsh-session-log-export` lets the Web interface download a session's full history: a `Download session log` menu item under the Session Header's more-actions button and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then click `Session log` in the Session Header or type `/export`; the browser downloads `dsh-session-.zip`. +Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Download session log` from the Session Header's more-actions menu or type `/export`; the browser downloads `dsh-session-.zip`. ### When to choose it diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 4e2eb00de9..706a8fcd13 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控制和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。 +`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 更多操作按钮下的 `下载 Session 日志` 菜单项与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控制和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 +当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `下载 Session 日志` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 ### 何时选择 diff --git a/packages/session-query/session-log-export/src/client/Dialog.tsx b/packages/session-query/session-log-export/src/client/Dialog.tsx index dc51918c61..58600aaf51 100644 --- a/packages/session-query/session-log-export/src/client/Dialog.tsx +++ b/packages/session-query/session-log-export/src/client/Dialog.tsx @@ -18,7 +18,7 @@ export type SessionLogDownloadDialogProps = & InjectFace /** - * Modal shared by the Session Header button and this browser's `/export` command. + * Modal shared by the Session Header download menu item and this browser's `/export` command. * @param props - Session runtime, bound controller state, actions, and localized copy. * @returns the modal portal contribution. */ diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.module.css b/packages/session-query/session-log-export/src/client/HeaderAction.module.css index d3989511e8..cc13d3f49d 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.module.css +++ b/packages/session-query/session-log-export/src/client/HeaderAction.module.css @@ -1,39 +1,25 @@ -.sessionLogButton { +/* The right Sidebar's 28px round header icon button: a 28px circle around a + 15px glyph, level with the header's corner controls. */ +.moreButton { display: inline-flex; + flex: none; align-items: center; justify-content: center; - height: 26px; - padding: 5px 10px; - gap: 5px; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 13px; - color: var(--dsw-alias-label-primary); + width: 28px; + height: 28px; + padding: 6px; + color: var(--dsw-alias-label-secondary); background: transparent; - font-family: var(--dsw-font-family); - font-size: 11px; - font-weight: 400; - line-height: 16px; + border: none; + border-radius: 28px; cursor: pointer; } -.sessionLogButton:hover:not(:disabled) { +.moreButton svg { + width: 15px; + height: 15px; +} + +.moreButton:hover { background: var(--dsw-alias-interactive-bg-hover); } - -.sessionLogButton:disabled { - color: var(--dsw-alias-label-dimmed); - cursor: wait; -} - -.sessionLogButton span, -.sessionLogButton svg { - flex: none; -} - -.sessionLogButton svg { - color: var(--dsw-alias-label-secondary); -} - -.sessionLogButton span { - white-space: nowrap; -} diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index faf6d43385..cb20cb9f9d 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -1,30 +1,46 @@ +import { useState } from 'react' import type { ReactNode } from 'react' -import { IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconDownloadOutline16, IconEllipsisOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx' import css from './HeaderAction.module.css' /** - * Render the Session Header export capsule and its shared result dialog. - * @param props - Session runtime, download controller, and localized dialog copy. + * Render the Session Header more-actions icon button, its download menu, and the shared result dialog. + * @param props - Session runtime, download controller, and localized copy. * @returns the persistent Header action and Session-scoped dialog. */ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadDialogProps): ReactNode { const { sessionId, useSessionLogDownload, request, t } = props const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) const busy = entry?.status === 'downloading' + const [open, setOpen] = useState(false) return ( <> - + { setOpen(false) }} + items={[{ id: 'download', label: t('menu.download'), icon: , disabled: busy }]} + onSelect={() => { + setOpen(false) + void request(sessionId) + }} + anchor={( + + )} + /> ) diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 29989f3dd4..54d68f2665 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -3,7 +3,8 @@ export const NS = 'session-log-download' /** Simplified-Chinese Session export strings. */ export const zh = { - 'header.action': 'Session 日志', + 'header.more': '更多操作', + 'menu.download': '下载 Session 日志', 'dialog.preparingTitle': '正在导出 Session', 'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。', 'dialog.successTitle': 'Session 导出已开始下载', @@ -15,7 +16,8 @@ export const zh = { /** English Session export strings. */ export const en: Record = { - 'header.action': 'Session log', + 'header.more': 'More actions', + 'menu.download': 'Download session log', 'dialog.preparingTitle': 'Exporting Session', 'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.', 'dialog.successTitle': 'Session download started', diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index f428e3ada3..aed71681c5 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -38,16 +38,29 @@ function bench() { afterEach(cleanup) describe('Session export Header action', () => { - it('renders the 111×32 text capsule and downloads through the shared controller', async () => { + it('opens the more-actions menu and downloads through the shared controller', async () => { const b = bench() - const button = b.view.getByRole('button', { name: 'Session log' }) + const button = b.view.getByRole('button', { name: 'More actions' }) expect(button.querySelector('svg')).not.toBeNull() + expect(button.getAttribute('aria-expanded')).toBe('false') fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(b.view.getByRole('menuitem', { name: 'Download session log' })) await waitFor(() => { expect(b.request).toHaveBeenCalledWith(SID) }) + expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull() expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy() }) - it('disables the capsule while either entry path downloads this Session', async () => { + it('closes the menu on Escape without downloading', () => { + const b = bench() + fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) + expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull() + expect(b.request).not.toHaveBeenCalled() + }) + + it('disables the download row while either entry path downloads this Session', async () => { const b = bench() let release!: (response: Response) => void const pending = new Promise((resolve) => { release = resolve }) @@ -62,11 +75,14 @@ describe('Session export Header action', () => { } as unknown as SessionLogDownloadDialogProps)} />) const download = controller.download(SID) - const button = b.view.getByRole('button', { name: 'Session log' }) + const button = b.view.getByRole('button', { name: 'More actions' }) await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('true') }) - expect((button as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(button) + const item = b.view.getByRole('menuitem', { name: 'Download session log' }) + expect((item as HTMLButtonElement).disabled).toBe(true) release(new Response('zip')) await download await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') }) + expect((item as HTMLButtonElement).disabled).toBe(false) }) }) diff --git a/packages/util/workspace-path/README.i18n.yaml b/packages/util/workspace-path/README.i18n.yaml index dbde1fc061..01f270263a 100644 --- a/packages/util/workspace-path/README.i18n.yaml +++ b/packages/util/workspace-path/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/workspace-path/README.md -README.md: d89969a8df83e1fcd396c086cddea518af813660 -README.zh.md: 2655fa8c354cc8b1091909aa57856667eb6129d9 +README.md: ed7cb3bc5089ac76058bd4c7447b1656b0ab2b24 +README.zh.md: 471f04af640d1121d8a0dc4f39da0d8c1f5a62d6 diff --git a/packages/util/workspace-path/README.md b/packages/util/workspace-path/README.md index d89969a8df..ed7cb3bc50 100644 --- a/packages/util/workspace-path/README.md +++ b/packages/util/workspace-path/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, derives Workspace titles from POSIX or Windows paths, and owns the `dsh-resource://file/…` address grammar that names a workspace file across the Sidebar and the resource model. It has no Cordis service or runtime state. +Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, derives Workspace titles from POSIX or Windows paths, splits a path into its directories and final segment for display, and owns the `dsh-resource://file/…` address grammar that names a workspace file across the Sidebar and the resource model. It has no Cordis service or runtime state. ## Table of Contents diff --git a/packages/util/workspace-path/README.zh.md b/packages/util/workspace-path/README.zh.md index 2655fa8c35..471f04af64 100644 --- a/packages/util/workspace-path/README.zh.md +++ b/packages/util/workspace-path/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录、从 POSIX 或 Windows 路径提取 Workspace 标题,并拥有在 Sidebar 与资源模型之间命名工作区文件的 `dsh-resource://file/…` 地址语法;它不提供 Cordis service,也不持有运行时状态。 +供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录、从 POSIX 或 Windows 路径提取 Workspace 标题、把路径拆成目录部分与末段供展示,并拥有在 Sidebar 与资源模型之间命名工作区文件的 `dsh-resource://file/…` 地址语法;它不提供 Cordis service,也不持有运行时状态。 ## 目录 diff --git a/packages/util/workspace-path/src/index.ts b/packages/util/workspace-path/src/index.ts index 976ca91a63..d05b56996a 100644 --- a/packages/util/workspace-path/src/index.ts +++ b/packages/util/workspace-path/src/index.ts @@ -61,6 +61,22 @@ export function workspaceTitleOf(path: string): string { return trimmed.slice(separator + 1) } +/** + * Split a path for display: the directories through their last separator, and + * the final segment after it. Both `/` and `\` separate, so a Windows path + * splits where its own segments end; trailing separators are dropped first, so + * a directory path names its own last segment. A path with no separator, or a + * separator-only path, is all name. + * @param path - file or directory path using POSIX or Windows separators. + * @returns the directory prefix (possibly empty) and the final segment. + */ +export function pathPartsOf(path: string): { readonly directory: string; readonly name: string } { + const trimmed = path.replace(/[/\\]+$/, '') + if (trimmed === '') return { directory: '', name: path } + const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + 1 + return { directory: trimmed.slice(0, cut), name: trimmed.slice(cut) } +} + export * from './file-address.ts' /** diff --git a/packages/util/workspace-path/tests/index.spec.ts b/packages/util/workspace-path/tests/index.spec.ts index fc032261c2..532771e638 100644 --- a/packages/util/workspace-path/tests/index.spec.ts +++ b/packages/util/workspace-path/tests/index.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { - abbreviateHomePath, fileAddressFor, isAbsoluteWorkspacePath, parseFileAddress, resolveWorkspacePath, workspaceTitleOf, + abbreviateHomePath, fileAddressFor, isAbsoluteWorkspacePath, parseFileAddress, pathPartsOf, resolveWorkspacePath, + workspaceTitleOf, } from '@deepseek-ai/dsh-util-workspace-path' describe('Workspace path helpers', () => { @@ -65,4 +66,16 @@ describe('Workspace path helpers', () => { expect(workspaceTitleOf('C:\\work\\project\\')).toBe('project') expect(workspaceTitleOf('/')).toBe('') }) + + it('splits a path for display after the last separator of either kind, keeping the separator with the directories', () => { + expect(pathPartsOf('/work/project/notes.md')).toEqual({ directory: '/work/project/', name: 'notes.md' }) + expect(pathPartsOf('C:\\work\\project\\notes.md')).toEqual({ directory: 'C:\\work\\project\\', name: 'notes.md' }) + expect(pathPartsOf('\\\\host\\share/notes.md')).toEqual({ directory: '\\\\host\\share/', name: 'notes.md' }) + expect(pathPartsOf('/work/project/')).toEqual({ directory: '/work/', name: 'project' }) + }) + + it('makes a separator-less or separator-only path all name', () => { + expect(pathPartsOf('notes.md')).toEqual({ directory: '', name: 'notes.md' }) + expect(pathPartsOf('/')).toEqual({ directory: '', name: '/' }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 643c12ce5f..03486dc393 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2816,6 +2816,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/snapshots/web/approval-composer/session.v3.jsonl b/snapshots/web/approval-composer/session.v3.jsonl index aa81175f9c..bd056f3555 100644 --- a/snapshots/web/approval-composer/session.v3.jsonl +++ b/snapshots/web/approval-composer/session.v3.jsonl @@ -6,12 +6,12 @@ {"type":"permission/preset","data":{"preset":"read-only"}} {"type":"sandbox/mode","data":{"mode":"read-only"}} {"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"preset read-only"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index 2c01d7575b..8038eab574 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - 'button "Run two shell commands: wait" [disabled]' - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/cordis-tool-round/session.v3.jsonl b/snapshots/web/cordis-tool-round/session.v3.jsonl index 1df66acfd1..5a7f451a93 100644 --- a/snapshots/web/cordis-tool-round/session.v3.jsonl +++ b/snapshots/web/cordis-tool-round/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} @@ -38,11 +38,11 @@ {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"The Cordis Plugin is running."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699480237,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788699480253,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The Cordis Plugin is running."}}},{"type":"chunk","time":1788699480270,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699480286,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use only Cordis tools. Call cordis_stop with pluginId \"snap-1\". After it succeeds, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:13}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use only Cordis tools. Call cordis_stop with pluginId \"snap-1\". After it succeeds, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:13}}"}]}} {"type":"turn/start","data":{"turn":3}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":3,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use only Cordis tools. Call cordis_stop with pluginId \"snap-1\". After it succeeds, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use only Cordis tools. Call cordis_stop with pluginId \"snap-1\". After it succeeds, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cordis-stop","name":"cordis_stop","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788699480345,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1788699480361,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cordis-stop","name":"cordis_stop","arguments":"{\"pluginId\":\"snap-1\"}"}}},{"type":"chunk","time":1788699480377,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788699480394,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":3,"step":1,"callId":"cordis-stop","name":"cordis_stop","arguments":"{\"pluginId\":\"snap-1\"}"}} {"type":"tool/result","data":{"turn":3,"step":1,"message":{"source":{"kind":"tool","callId":"cordis-stop"},"content":[{"type":"tool-result","toolCallId":"cordis-stop","content":[{"type":"text","text":"Dynamic Plugin snap-1 is stopped; its definition and versions remain."}],"isError":false}],"role":"user","id":"{{message:15}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"} diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index aa741273ff..5310a3d45c 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -3,10 +3,9 @@ - button "Use only Cordis tools. First" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 8032c0718a..ee31777a15 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -3,7 +3,7 @@ ## Sidebar tabs - Default tab: Files -- Files close buttons (alone -> with guide -> restored): 0 -> 0 -> 0 +- Files close buttons (alone -> with guide -> restored): 1 -> 1 -> 1 - Manual guide close buttons: 1 - Add buttons (Files -> guide -> Files): 1 -> 0 -> 1 diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 6939707ba4..443554c243 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -3,10 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 75bd8bad1b..3285e3c511 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -3,10 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/feedback-command/session.v3.jsonl b/snapshots/web/feedback-command/session.v3.jsonl index 23b742e4e0..25f66b9d22 100644 --- a/snapshots/web/feedback-command/session.v3.jsonl +++ b/snapshots/web/feedback-command/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 6939707ba4..443554c243 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -3,10 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 75bd8bad1b..3285e3c511 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -3,10 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/file-upload-round/session.v3.jsonl b/snapshots/web/file-upload-round/session.v3.jsonl index 98ce90b642..630305e28f 100644 --- a/snapshots/web/file-upload-round/session.v3.jsonl +++ b/snapshots/web/file-upload-round/session.v3.jsonl @@ -3,12 +3,12 @@ {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} {"type":"model/selection","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}} diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index cbddcb3d63..f88f17d869 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -3,10 +3,9 @@ - button "Read the attached file with" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/fresh-round-trip/session.v3.jsonl b/snapshots/web/fresh-round-trip/session.v3.jsonl index 4d79933945..43ff133d17 100644 --- a/snapshots/web/fresh-round-trip/session.v3.jsonl +++ b/snapshots/web/fresh-round-trip/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 0d877cc2e6..9f05223711 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -3,10 +3,9 @@ - button "Use the bash tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 61079e8d61..9e224849aa 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -3,10 +3,9 @@ - button "Use the bash tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index c88d6d3c40..87fac187c2 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -3,10 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index bcd881ffef..582bae956e 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -3,10 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index f7e902be44..6bc4d76c3a 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -3,10 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 47c78b4156..561edd89e4 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -3,10 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/lifecycle-chrome/session.v3.jsonl b/snapshots/web/lifecycle-chrome/session.v3.jsonl index 8fb0d064be..09151408e9 100644 --- a/snapshots/web/lifecycle-chrome/session.v3.jsonl +++ b/snapshots/web/lifecycle-chrome/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 906f3a9ed3..7eaeac6b7a 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index d50dc2c162..ee6545830b 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index d6cd35dcb3..72a002e385 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 3dfa3da484..02387b88de 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index a9c40b178e..388c0cb61f 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 6c3591cc67..69dfa8ead7 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 21ce478e78..771ea4e6ef 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 505805bc35..52788933b1 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/live-interactions/session.v3.jsonl b/snapshots/web/live-interactions/session.v3.jsonl index 19063e1a84..2eeb967f7a 100644 --- a/snapshots/web/live-interactions/session.v3.jsonl +++ b/snapshots/web/live-interactions/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 57607ba405..e628800c81 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index f730fe2b50..fecd158d57 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -3,10 +3,9 @@ - button "Use the bash tool to" [disabled] - img - text: Minimal mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/open-in-app-ssh/header.expected.md b/snapshots/web/open-in-app-ssh/header.expected.md index 54d84e693e..12ecc45a49 100644 --- a/snapshots/web/open-in-app-ssh/header.expected.md +++ b/snapshots/web/open-in-app-ssh/header.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/permission-policy-context/session.v3.jsonl b/snapshots/web/permission-policy-context/session.v3.jsonl index 36e4eb3f92..b23cbc6254 100644 --- a/snapshots/web/permission-policy-context/session.v3.jsonl +++ b/snapshots/web/permission-policy-context/session.v3.jsonl @@ -6,12 +6,12 @@ {"type":"permission/preset","data":{"preset":"read-only"}} {"type":"sandbox/mode","data":{"mode":"read-only"}} {"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"preset read-only"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} @@ -25,13 +25,13 @@ {"type":"approval/policy","data":{"policy":"never"}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"},"role":"user","id":"{{message:5}}"}]}} {"type":"command/done","data":{"commandId":"{{command:2}}","kind":"success","text":"preset danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:6}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:6}}"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."},{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66},"stream":[{"type":"chunk","time":1788699482952,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699482952,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," D","SH"," file"," sand","box"," restrictions","."," According"," to"," the"," current"," runtime"," context"," snapshot",","," the"," file"," policy"," is"," \"","danger","-full","-access","\""," which"," means"," no"," restrictions"," on"," file"," operations","."," Also",","," approval"," prompts"," are"," disabled",","," so"," I"," should"," not"," request"," sand","box"," escalation",".\n\n","Let"," me"," answer"," directly"," in"," one"," sentence"," without"," calling"," tools","."]},{"type":"chunk","time":1788699482953,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699482953,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No",","," the"," D","SH"," file"," sand","box"," currently"," grants"," full"," access"," —"," it"," does"," not"," restrict"," file"," operations","."]},{"type":"chunk","time":1788699482953,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."}}},{"type":"chunk","time":1788699482953,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}}},{"type":"chunk","time":1788699482953,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66}}},{"type":"chunk","time":1788699482953,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} @@ -42,13 +42,13 @@ {"type":"approval/policy","data":{"policy":"ask"}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"},"role":"user","id":"{{message:9}}"}]}} {"type":"command/done","data":{"commandId":"{{command:3}}","kind":"success","text":"preset workspace-write"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:3}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:10}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:3}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:10}}"}]}} {"type":"turn/start","data":{"turn":3}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":3,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:3}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:10}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:3}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:10}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:11}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."},{"type":"text","text":"WORKSPACE_POLICY_SEEN"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":245,"outputTokens":27,"cacheReadTokens":14976,"reasoningTokens":18},"stream":[{"type":"chunk","time":1788699483136,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699483136,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WORK","SP","ACE","_P","OL","ICY","_SE","EN","\"."]},{"type":"chunk","time":1788699483136,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699483136,"index":1,"dt":[0,0,1,0,0,0,0],"texts":["WORK","SP","ACE","_P","OL","ICY","_SE","EN"]},{"type":"chunk","time":1788699483137,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."}}},{"type":"chunk","time":1788699483137,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKSPACE_POLICY_SEEN"}}},{"type":"chunk","time":1788699483137,"chunk":{"type":"usage","usage":{"inputTokens":245,"outputTokens":27,"cacheReadTokens":14976,"reasoningTokens":18}}},{"type":"chunk","time":1788699483137,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":3,"step":1}} @@ -57,11 +57,11 @@ {"type":"permission/preset","data":{"preset":"read-only"}} {"type":"sandbox/mode","data":{"mode":"read-only"}} {"type":"command/done","data":{"commandId":"{{command:4}}","kind":"success","text":"preset read-only"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion."}],"source":{"kind":"user","rpcId":"{{rpc:4}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:13}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion."}],"source":{"kind":"user","rpcId":"{{rpc:4}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:13}}"}]}} {"type":"turn/start","data":{"turn":4}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":4,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion."}],"source":{"kind":"user","rpcId":"{{rpc:4}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion."}],"source":{"kind":"user","rpcId":"{{rpc:4}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:14}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":4,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."},{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35},"stream":[{"type":"chunk","time":1788699483387,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699483387,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," policy"," is"," read","-only",","," so"," write"," tools"," will"," be"," denied","."," I"," should"," attempt"," it"," normally"," and"," follow"," the"," denial","/"," escalation"," guidance","."," Let"," me"," try"," to"," write"," the"," file","."]},{"type":"chunk","time":1788699483387,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699483387,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"","}"]},{"type":"chunk","time":1788699483387,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."}}},{"type":"chunk","time":1788699483387,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}}},{"type":"chunk","time":1788699483387,"chunk":{"type":"usage","usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35}}},{"type":"chunk","time":1788699483387,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":4,"step":1,"callId":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}} diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 43c4b97487..122a6b3671 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -3,10 +3,9 @@ - 'button "Plan a small change: add" [disabled]' - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index c04fb13bad..b8d17dad82 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -3,10 +3,9 @@ - 'button "Plan a small change: add" [disabled]' - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index eaa618d966..1a79b00463 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -3,10 +3,9 @@ - button "Use one run_code program to" [disabled] - img - text: PTC mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/ptc-round/session.v3.jsonl b/snapshots/web/ptc-round/session.v3.jsonl index 75ff98347c..8c731d866f 100644 --- a/snapshots/web/ptc-round/session.v3.jsonl +++ b/snapshots/web/ptc-round/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 94727a92fe..1b55072dc5 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -3,10 +3,9 @@ - 'button "Using ONE run_code program: run" [disabled]' - img - text: PTC mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index a57598a5c3..e0c69dac00 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 7d7da25ef6..04cceb554a 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index d23da91aed..a6a85d1e17 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/question-composer/session.v3.jsonl b/snapshots/web/question-composer/session.v3.jsonl index 28b2ae29ad..8903b585ba 100644 --- a/snapshots/web/question-composer/session.v3.jsonl +++ b/snapshots/web/question-composer/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 6645c331fd..e995bbf5eb 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index d0586fb967..4078e36661 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 79f239edd8..2ffd53409b 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -3,10 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 55fb2e4783..300ae138f4 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 1fbeabd838..3a0f021b14 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index d2b2a0d9de..eeabc5d6b2 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index e7aa55934d..67057867a6 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 7ea64dd0f6..b0c1e9fd8e 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 68d4ef90b7..0e59f304d4 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -3,10 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 5b9ff3b332..2d563036c5 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 94e9f084b0..e4d038c87d 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/seeded-history/file-preview.expected.md b/snapshots/web/seeded-history/file-preview.expected.md index b2069d5278..9cc9064145 100644 --- a/snapshots/web/seeded-history/file-preview.expected.md +++ b/snapshots/web/seeded-history/file-preview.expected.md @@ -1,6 +1,6 @@ - text: {{cwd}}/a.txt - button "Open with": Plain text -- button "Wrap lines" [pressed]: +- button "Line wrap" [pressed]: - img - button "Read the file again": - img diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 79a4b9d1f8..0f1cad4f7b 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 920f4b9c4d..133ef8d008 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 1e9813be8f..3365c8f244 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -1,10 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Load the editing-cordis-compositions ski" [disabled] - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 94b2356941..5d3e0929a4 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/steering/session.v3.jsonl b/snapshots/web/steering/session.v3.jsonl index 5049ec6a6d..d6d5c5ba65 100644 --- a/snapshots/web/steering/session.v3.jsonl +++ b/snapshots/web/steering/session.v3.jsonl @@ -2,26 +2,26 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"session/title","data":{"title":"Use the ask_user_question tool to","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:4}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:4}}"}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:4}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:4}}"}]}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"steering-question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699399190,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699399690,"index":0,"dt":[],"id":"steering-question","name":"ask_user_question","args":["{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"]},{"type":"chunk","time":1788699400192,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"steering-question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"}}},{"type":"chunk","time":1788699400693,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699401194,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"steering-question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"checkpoint\",\"question\":\"Ready to continue?\",\"header\":\"Checkpoint\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"steering-question"},"content":[{"type":"tool-result","toolCallId":"steering-question","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Great, let's move forward. BANANA!"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788699402139,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699402640,"index":0,"dt":[],"texts":["Great, let's move forward. BANANA!"]},{"type":"chunk","time":1788699403142,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}},{"type":"chunk","time":1788699403642,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788699404143,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index fc4fa021e5..5341248e66 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 0ff456ca8a..8eafc70564 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -3,10 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/subagent-conversation/nested.expected.md b/snapshots/web/subagent-conversation/nested.expected.md index 1ed0fc0b02..2825ca2ba0 100644 --- a/snapshots/web/subagent-conversation/nested.expected.md +++ b/snapshots/web/subagent-conversation/nested.expected.md @@ -5,10 +5,9 @@ - 'button "Switch subagent: event-sourcing researcher"': event-sourcing researcher - text: / - 'button "Switch subagent: example editor"': example editor - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 5779430798..598cf5a252 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -8,10 +8,9 @@ - img - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 535ff4429f..d855468c64 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -8,10 +8,9 @@ - img - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index e7f7483bc3..5e1f10df42 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -5,10 +5,9 @@ - 'button "Switch subagent: event-sourcing researcher"': event-sourcing researcher - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 4f9b0b72f3..fd32496955 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -3,10 +3,9 @@ - button "Begin your reply with the" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 2108bafd30..5d89f06e46 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -3,10 +3,9 @@ - button "Begin your reply with the" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index d4fced7fe3..ecb2da574e 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -3,10 +3,9 @@ - button "Begin your reply with the" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/turn-tail-actions/session.v3.jsonl b/snapshots/web/turn-tail-actions/session.v3.jsonl index 4f4aaeb433..12d6ac0daa 100644 --- a/snapshots/web/turn-tail-actions/session.v3.jsonl +++ b/snapshots/web/turn-tail-actions/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Begin your reply with the plain sentence \"Reading the workspace now.\" as text, and in that same message call the bash tool with the command \"echo alpha\". After the tool result, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Begin your reply with the plain sentence \"Reading the workspace now.\" as text, and in that same message call the bash tool with the command \"echo alpha\". After the tool result, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Begin your reply with the plain sentence \"Reading the workspace now.\" as text, and in that same message call the bash tool with the command \"echo alpha\". After the tool result, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Begin your reply with the plain sentence \"Reading the workspace now.\" as text, and in that same message call the bash tool with the command \"echo alpha\". After the tool result, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index fd6de1de07..839f26e361 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -3,10 +3,9 @@ - button "Begin your reply with the" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 916569dc9c..1c8edb386d 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -3,10 +3,9 @@ - button "Begin your reply with the" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected] diff --git a/snapshots/web/web-search-round/session.v3.jsonl b/snapshots/web/web-search-round/session.v3.jsonl index e2000d1e21..7d11c4a7b3 100644 --- a/snapshots/web/web-search-round/session.v3.jsonl +++ b/snapshots/web/web-search-round/session.v3.jsonl @@ -2,12 +2,12 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use web_search once with queries [\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]. Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use web_search once with queries [\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]. Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use web_search once with queries [\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]. Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use web_search once with queries [\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]. Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 1aa9b33fc1..96fd77fbb6 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -3,10 +3,9 @@ - button "Use web_search once with queries" [disabled] - img - text: Standard mode - - button "Session log": - - text: Session log + - button "More actions": - img - - button "Open the sidebar": + - button "Open right sidebar": - img - tablist: - tab "Chat" [selected]