Răsfoiți Sursa

feat(sidebar): live tab titles with file-type icons and disambiguated paths

Yif 1 lună în urmă
părinte
comite
5ee343f735
30 a modificat fișierele cu 745 adăugiri și 160 ștergeri
  1. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  2. 4 4
      packages/client/ui-sidebar-documentpreview/README.md
  3. 4 4
      packages/client/ui-sidebar-documentpreview/README.zh.md
  4. 133 17
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css
  5. 88 37
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  6. 24 0
      packages/client/ui-sidebar-documentpreview/src/client/TextTitle.tsx
  7. 27 11
      packages/client/ui-sidebar-documentpreview/src/client/icons.tsx
  8. 9 3
      packages/client/ui-sidebar-documentpreview/src/client/index.ts
  9. 14 12
      packages/client/ui-sidebar-documentpreview/src/client/locales.ts
  10. 6 4
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  11. 71 1
      packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx
  12. 28 0
      packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx
  13. 2 2
      packages/client/ui-sidebar-files/README.i18n.yaml
  14. 4 3
      packages/client/ui-sidebar-files/README.md
  15. 4 3
      packages/client/ui-sidebar-files/README.zh.md
  16. 101 21
      packages/client/ui-sidebar-files/src/client/FilesBody.module.css
  17. 57 13
      packages/client/ui-sidebar-files/src/client/FilesBody.tsx
  18. 37 0
      packages/client/ui-sidebar-files/src/client/FilesTitle.tsx
  19. 0 1
      packages/client/ui-sidebar-files/src/client/definition.ts
  20. 10 4
      packages/client/ui-sidebar-files/src/client/index.ts
  21. 3 5
      packages/client/ui-sidebar-files/src/client/locales.ts
  22. 4 2
      packages/client/ui-sidebar-files/tests/apply.client.spec.ts
  23. 0 1
      packages/client/ui-sidebar-files/tests/definition.client.spec.ts
  24. 57 5
      packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx
  25. 22 0
      packages/client/ui-sidebar-files/tests/files-title.client.spec.tsx
  26. 2 2
      packages/util/workspace-path/README.i18n.yaml
  27. 1 1
      packages/util/workspace-path/README.md
  28. 1 1
      packages/util/workspace-path/README.zh.md
  29. 16 0
      packages/util/workspace-path/src/index.ts
  30. 14 1
      packages/util/workspace-path/tests/index.spec.ts

+ 2 - 2
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

+ 4 - 4
packages/client/ui-sidebar-documentpreview/README.md

@@ -26,8 +26,8 @@ Preview readable files in the right Sidebar and choose among registered renderer
 <a id="what-it-registers"></a>
 ## 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<ArrayBuffer>` 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.
 
 <a id="dev-note"></a>

+ 4 - 4
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -26,8 +26,8 @@ kind: "package-reference"
 <a id="what-it-registers"></a>
 ## 注册了什么
 
-- **类型** —— `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<ArrayBuffer>`,供 `{ 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 的是同一批元素。
 
 <a id="dev-note"></a>

+ 133 - 17
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:hover {
-  color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-hover);
+.tool svg {
+  width: 15px;
+  height: 15px;
 }
 
-.toolOn {
+.tool:hover {
   color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
-
 .viewerTool {
   width: auto;
   max-width: 160px;

+ 88 - 37
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<HTMLDivElement | null>,
+  text: RefObject<HTMLSpanElement | null>,
+  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<readonly DocumentPreviewDefinition[]> }
@@ -70,7 +94,11 @@ export function TextPreview({
   const mode = selected?.loading
   const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
+  const pathRef = useRef<HTMLDivElement | null>(null)
+  const pathTextRef = useRef<HTMLSpanElement | null>(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,7 +170,7 @@ 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
@@ -188,7 +216,12 @@ export function TextPreview({
           </p>
         )}
       <div className={css.header}>
-        <div className={css.path} title={displayPath} data-textpreview-path>{displayPath}</div>
+        <div ref={pathRef} className={css.path} title={displayPath} data-textpreview-path>
+          <span ref={pathTextRef} className={css.pathText}>
+            {directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
+            <span className={css.pathName}>{name}</span>
+          </span>
+        </div>
         <Menu
           open={menuOpen}
           anchor={(
@@ -205,28 +238,30 @@ export function TextPreview({
           dense
         />
         {selected.wrap === true && (
+          <Tooltip label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')} side="bottom" delayMs={500}>
+            <button
+              type="button"
+              className={css.tool}
+              aria-pressed={state.wrap}
+              aria-label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')}
+              data-textpreview-tool="wrap"
+              onClick={() => { actions.toggledWrap(tab.id) }}
+            >
+              {state.wrap ? <IconNowrapFill16 /> : <IconWrapFill16 />}
+            </button>
+          </Tooltip>
+        )}
+        <Tooltip label={t('reload')} side="bottom" delayMs={500}>
           <button
             type="button"
-            className={clsx(css.tool, state.wrap && css.toolOn)}
-            aria-pressed={state.wrap}
-            aria-label={t('wrap')}
-            title={t('wrap')}
-            data-textpreview-tool="wrap"
-            onClick={() => { actions.toggledWrap(tab.id) }}
+            className={css.tool}
+            aria-label={t('reload')}
+            data-textpreview-tool="reload"
+            onClick={reload}
           >
-            <IconWrapOutline16 />
+            <IconRefreshOutline16 />
           </button>
-        )}
-        <button
-          type="button"
-          className={css.tool}
-          aria-label={t('reload')}
-          title={t('reload')}
-          data-textpreview-tool="reload"
-          onClick={reload}
-        >
-          <IconRefreshOutline16 />
-        </button>
+        </Tooltip>
       </div>
       <div
         ref={bodyRef}
@@ -249,19 +284,35 @@ export function TextPreview({
           entryKey: selected.id, hookContext: useTabInfo,
           fallback: <p className={css.statusLine}>{t('rendererUnavailable', { name: selected.title() })}</p>,
         })}
-        {current?.failure !== undefined && (
-          <p className={css.statusLine} data-textpreview-failed={current.failure.code}>
-            <span>{failureLine(t, current.failure)}</span>
-            <button
-              type="button"
-              className={css.action}
-              data-textpreview-retry
-              onClick={loadNext}
-            >
-              {t('retry')}
-            </button>
-          </p>
-        )}
+        {current?.failure !== undefined && (hasContent
+          ? (
+            <p className={css.statusLine} data-textpreview-failed={current.failure.code}>
+              <span>{failureLine(t, current.failure)}</span>
+              <button
+                type="button"
+                className={css.action}
+                data-textpreview-retry
+                onClick={loadNext}
+              >
+                {t('retry')}
+              </button>
+            </p>
+          )
+          : (
+            <div className={css.empty} data-textpreview-failed={current.failure.code}>
+              <FileTypeIcon kind={classifyFileType(name)} size={36} className={css.emptyIcon} />
+              <p className={css.emptyLine}>{failureLine(t, current.failure)}</p>
+              <button
+                type="button"
+                className={css.retry}
+                data-textpreview-retry
+                onClick={loadNext}
+              >
+                <IconRefreshOutline16 size={14} />
+                {t('retry')}
+              </button>
+            </div>
+          ))}
         {mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && (
           <button
             type="button"

+ 24 - 0
packages/client/ui-sidebar-documentpreview/src/client/TextTitle.tsx

@@ -0,0 +1,24 @@
+/**
+ * The text type's chip title: the file type's coloured sheet before the name
+ * the registry captured at open time. Registered under
+ * `sidebar.right.pane.tab.title`; without it the chip would show the bare name.
+ */
+import type { ReactNode } from 'react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { FileTypeIcon, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
+import css from './TextPreview.module.css'
+
+/**
+ * The title as the chip and a floating panel's header show it.
+ * @param props - the tab information hook.
+ * @returns the type's 16px sheet followed by the tab's title text.
+ */
+export function TextTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode {
+  const { tab } = useTabInfo()
+  return (
+    <>
+      <FileTypeIcon kind={classifyFileType(tab.title)} size={16} className={css.titleIcon} />
+      {tab.title}
+    </>
+  )
+}

+ 27 - 11
packages/client/ui-sidebar-documentpreview/src/client/icons.tsx

@@ -2,26 +2,42 @@
  * Glyphs this package draws that the shared icon set does not carry yet.
  * Same props contract as `@deepseek-ai/dsh-client-ui-primitives` icons, so a
  * shared replacement is a one-line import change.
+ *
+ * The wrap control swaps between the two glyphs below to preview the mode a
+ * click switches to, so neither needs a pressed style.
  */
 import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 
-/** Three text lines, the middle one turning back under itself. */
-export const IconWrapOutline16 = ({ size = 16, className }: IconProps) => (
+/** Two margin bars, a straight arrow running to the right one: lines run past the edge. */
+export const IconNowrapFill16 = ({ size = 16, className }: IconProps) => (
   <svg
     width={size}
     height={size}
     className={className}
-    viewBox="0 0 16 16"
+    viewBox="0 0 24 24"
     fill="none"
-    stroke="currentColor"
-    strokeWidth="1.3"
-    strokeLinecap="round"
-    strokeLinejoin="round"
     xmlns="http://www.w3.org/2000/svg"
   >
-    <path d="M2.5 4h11" />
-    <path d="M2.5 8h8.5a2.5 2.5 0 0 1 0 5H9.5" />
-    <path d="M11 11.5 9.5 13l1.5 1.5" />
-    <path d="M2.5 12h3.5" />
+    <path
+      d="M1.5 2.5H3.5V21.5H1.5V2.5ZM20.5 2.5H22.5V21.5H20.5V2.5ZM14 9L19 12L14 15V13H5V11H14V9Z"
+      fill="currentColor"
+    />
+  </svg>
+)
+
+/** Two margin bars, an arrow sweeping around and back left: lines turn under themselves. */
+export const IconWrapFill16 = ({ size = 16, className }: IconProps) => (
+  <svg
+    width={size}
+    height={size}
+    className={className}
+    viewBox="0 0 24 24"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      d="M1.5 2.5H3.5V21.5H1.5V2.5ZM20.5 2.5H22.5V21.5H20.5V2.5ZM6.75 5H11.5A6 6 0 0 1 12 16.98V19L7 16L12 13V14.97A4 4 0 0 0 11.5 7H6.75V5Z"
+      fill="currentColor"
+    />
   </svg>
 )

+ 9 - 3
packages/client/ui-sidebar-documentpreview/src/client/index.ts

@@ -2,8 +2,9 @@
  * Browser half: register `text` as a right-Sidebar tab type.
  *
  * The type reaches the Sidebar through its public path only: the definition into
- * `ctx.sidebarRightTabs` and the body into the keyed `sidebar.right.pane.tab`
- * seat under the definition's `id`. Nothing here reaches into the Sidebar's store, its
+ * `ctx.sidebarRightTabs`, the body into the keyed `sidebar.right.pane.tab`
+ * seat, and the chip title into `sidebar.right.pane.tab.title`, both under the
+ * definition's `id`. Nothing here reaches into the Sidebar's store, its
  * panes, or its sequence. The file's metadata comes from the standard
  * `useResource`, served by the `file` provider; the content is this type's own
  * business, read through its face. Every import from another
@@ -21,6 +22,7 @@ import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
 import type { WorkspaceFileParams } from '@deepseek-ai/dsh-api-workspace-files/client'
 import { TextPreview } from './TextPreview.tsx'
 import type { TextPreviewInjected } from './TextPreview.tsx'
+import { TextTitle } from './TextTitle.tsx'
 import { TEXTPREVIEW_ID, textDefinition } from './definition.ts'
 import { textFace } from './face.ts'
 import { createReadPage } from './rpc.ts'
@@ -76,7 +78,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
 
 /**
- * Client plugin body: register the type, its dictionaries, and 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, copy, and the Remote face.
  */
 export function apply(ctx: ClientContext): void {
@@ -102,6 +104,10 @@ export function apply(ctx: ClientContext): void {
     },
     TextPreview,
   )), 'ui-sidebar-documentpreview: text body')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab.title', key: TEXTPREVIEW_ID },
+    TextTitle,
+  )), 'ui-sidebar-documentpreview: text title')
   registerText(ctx)
   registerMarkdown(ctx)
   registerHtml(ctx)

+ 14 - 12
packages/client/ui-sidebar-documentpreview/src/client/locales.ts

@@ -10,18 +10,19 @@
 export const zh = {
   loading: '正在读取…',
   loadMore: '加载更多',
-  changed: '文件已被修改,显示的还是旧内容。',
+  changed: '文件已更新,当前显示为旧内容。',
   reloadNow: '重新载入',
   reload: '重新读取文件',
-  wrap: '自动换行',
+  'wrap.enable': '自动换行',
+  'wrap.disable': '取消换行',
   openWith: '打开方式',
   'viewer.text': '纯文本',
   resourceUnavailable: '文件资源服务不可用。',
   rendererUnavailable: '预览器 {name} 不可用。',
-  'error.notFound': '这个文件不在了。可能已被移动或删除。',
-  'error.tooLarge': '请求读取的内容太大,超过了 {limit} 的上限。',
-  'error.notText': '这不是文本文件,没法在这里查看。',
-  'error.notRegularFile': '这不是一个普通文件,没有可显示的文本。',
+  'error.notFound': '文件不存在,可能已被移动或删除。',
+  'error.tooLarge': '单页内容超过 {limit} 上限,无法读取。',
+  'error.notText': '非文本文件,暂时无法预览。',
+  'error.notRegularFile': '该路径不是普通文件,没有可显示的内容。',
   'error.unavailable': '读取失败:{message}',
   retry: '重试',
 } satisfies Record<string, string>
@@ -33,18 +34,19 @@ export type SidebarDocumentPreviewKey = keyof typeof zh
 export const en = {
   loading: 'Reading…',
   loadMore: 'Load more',
-  changed: 'The file has changed; this is the older text.',
+  changed: 'The file has changed, showing the previous content.',
   reloadNow: 'Reload',
   reload: 'Read the file again',
-  wrap: 'Wrap lines',
+  'wrap.enable': 'Turn on line wrap',
+  'wrap.disable': 'Turn off line wrap',
   openWith: 'Open with',
   'viewer.text': 'Plain text',
   resourceUnavailable: 'The file resource service is unavailable.',
   rendererUnavailable: 'The {name} preview is unavailable.',
-  'error.notFound': 'That file is gone. It may have been moved or deleted.',
-  'error.tooLarge': 'The requested content exceeds the {limit} read limit.',
-  'error.notText': 'That is not a text file, so it cannot be shown here.',
-  'error.notRegularFile': 'That is not a regular file, so it has no text to show.',
+  'error.notFound': 'File not found. It may have been moved or deleted.',
+  'error.tooLarge': 'This page exceeds the {limit} limit and cannot be read.',
+  'error.notText': 'Not a text file, preview is unavailable for now.',
+  'error.notRegularFile': 'Not a regular file, nothing to display.',
   'error.unavailable': 'Read failed: {message}',
   retry: 'Retry',
 } satisfies Record<SidebarDocumentPreviewKey, string>

+ 6 - 4
packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts

@@ -14,6 +14,7 @@ import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { TextPreview } from '../src/client/TextPreview.tsx'
+import { TextTitle } from '../src/client/TextTitle.tsx'
 import { TextBody } from '../src/client/text/TextBody.tsx'
 import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
 import { MarkdownBody } from '../src/client/markdown/MarkdownBody.tsx'
@@ -31,9 +32,9 @@ import { FILE, SESSION, TAB_ID, page } from './fixtures.client.ts'
 interface Recorded {
   name: string
   key: string
-  locale: string
-  store: unknown
-  inject: unknown
+  locale?: string
+  store?: unknown
+  inject?: unknown
   component: unknown
 }
 
@@ -79,7 +80,7 @@ describe('ui-sidebar-documentpreview 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, the body with a store and a face', async () => {
     const { tabs, registered, dictionaries } = await boot()
     expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback')
     expect(tabs.get(TEXTPREVIEW_KIND)?.id).toBe(TEXTPREVIEW_ID)
@@ -88,6 +89,7 @@ describe('ui-sidebar-documentpreview apply', () => {
     // 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', TEXTPREVIEW_ID, 'sidebarDocumentPreview', TextPreview],
+      ['sidebar.right.pane.tab.title', TEXTPREVIEW_ID, undefined, TextTitle],
       ['sidebar.right.tab.document', PLAIN_BODY_ID, undefined, TextBody],
       ['sidebar.right.tab.document', MARKDOWN_BODY_ID, 'documentMarkdown', MarkdownBody],
       ['sidebar.right.tab.document', HTML_BODY_ID, 'documentHtml', HtmlBody],

+ 71 - 1
packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

@@ -151,6 +151,56 @@ describe('TextPreview — pages', () => {
     expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH)
   })
 
+  it('marks the 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 h = harness({ 1: page(1, ['one'], true) })
+      const view = render(<TextPreview {...h.props()} />)
+      await settle()
+      const path = view.container.querySelector<HTMLElement>('[data-textpreview-path]')
+      const text = path?.firstElementChild
+      expect(path?.hasAttribute('data-textpreview-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-textpreview-path-clipped')).toBe(true)
+
+      boxWidth = 300
+      act(() => { observer.fire() })
+      expect(path?.hasAttribute('data-textpreview-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('reads the first page on first mount and draws its lines, offering the next', async () => {
     const h = harness({ 1: page(1, ['one', 'two', 'three'], false) })
     const view = render(<TextPreview {...h.props()} />)
@@ -211,8 +261,11 @@ describe('TextPreview — pages', () => {
     const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
     const view = render(<TextPreview {...h.props()} />)
     await settle()
-    expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
+    const failed = view.container.querySelector('[data-textpreview-failed]')
+    expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
     expect(view.container.textContent).toContain('error.notText')
+    // Nothing read yet: the failure stands as the body, under the file's type sheet.
+    expect(failed?.querySelector('svg')).not.toBeNull()
     expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
     h.script(1, page(1, ['one'], true))
     click(view.container, '[data-textpreview-retry]')
@@ -222,6 +275,23 @@ describe('TextPreview — pages', () => {
     expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
   })
 
+  it('says why a later page failed on a line under the pages already read', async () => {
+    const h = harness({ 1: page(1, ['a'], false), 2: failure('workspace-file/too-large', { path: PATH, limit: 1024 }) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    click(view.container, '[data-textpreview-more]')
+    await settle()
+    const failed = view.container.querySelector('[data-textpreview-failed]')
+    expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/too-large')
+    expect(failed?.querySelector('svg')).toBeNull()
+    expect(lines(view.container)).toEqual(['a\n'])
+    h.script(2, page(2, ['b'], true))
+    click(view.container, '[data-textpreview-retry]')
+    await settle()
+    expect(lines(view.container)).toEqual(['a\n', 'b\n'])
+    expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
+  })
+
   it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => {
     const h = harness({ 1: page(1, ['a', 'b'], true) })
     const view = render(<TextPreview {...h.props()} />)

+ 28 - 0
packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx

@@ -0,0 +1,28 @@
+// @vitest-environment jsdom
+/** The chip title: the file type's sheet, then the name the registry captured. */
+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 { TextTitle } from '../src/client/TextTitle.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('TextTitle', () => {
+  it('draws the type sheet before the title text, sized to the chip line', () => {
+    const { container } = render(<TextTitle {...props('README.md')} />)
+    const svg = container.querySelector('svg')
+    expect(svg?.getAttribute('width')).toBe('16')
+    expect(svg?.getAttribute('aria-hidden')).toBe('true')
+    expect(container.textContent).toBe('README.md')
+  })
+
+  it('picks the sheet from the title\'s extension', () => {
+    const markdown = render(<TextTitle {...props('notes.md')} />).container.querySelector('path')?.getAttribute('fill')
+    const pdf = render(<TextTitle {...props('paper.pdf')} />).container.querySelector('path')?.getAttribute('fill')
+    expect(markdown).not.toBe(pdf)
+  })
+})

+ 2 - 2
packages/client/ui-sidebar-files/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-files/README.md
-README.md: 6e9e305b15058797156f832a56a29c095b0641f3
-README.zh.md: 95a15444182a988fdf512735be4e0fbfcaad6bdd
+README.md: 89dc8ca0d3b34c7f2e0a720e959d9a163beb533f
+README.zh.md: 166e04bde8a61392248f14b43f71f2b88a4332bd

+ 4 - 3
packages/client/ui-sidebar-files/README.md

@@ -25,14 +25,15 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree,
 ## What it registers
 
 - **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type.
-- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: the tree, with its one control, reload, at the right of its header row.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header.
+- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a 16px folder sheet before the type's label. The tree's own rows never draw this sheet.
 
-Six source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `locales.ts` (what it says), and `index.ts` (the wiring).
+Seven source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
 
 <a id="the-tree"></a>
 ## The tree
 
-The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and labelled by `workspaceTitleOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
+The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
 
 | Entry type | Row |
 |---|---|

+ 4 - 3
packages/client/ui-sidebar-files/README.zh.md

@@ -25,14 +25,15 @@ kind: "package-reference"
 ## 注册了什么
 
 - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。
-- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:树本身,以及它唯一的控件、位于标题行右端的重新读取。
+- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。
+- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` 坑位:类型标签前的一枚 16px 文件夹图标。树本身的行不画这枚图标。
 
-`src/client/` 下六个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`locales.ts`(它说什么)、`index.ts`(接线)。
+`src/client/` 下七个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
 
 <a id="the-tree"></a>
 ## 树
 
-根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标签由 `@deepseek-ai/dsh-util-workspace-path` 的 `workspaceTitleOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
+根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
 
 | 条目类型 | 行 |
 |---|---|

+ 101 - 21
packages/client/ui-sidebar-files/src/client/FilesBody.module.css

@@ -1,23 +1,89 @@
+/* The pane body is a block scroller with a definite height, so the tree takes
+   that height outright: the header row stays put and the levels below are the
+   one scroller. */
 .root {
   display: flex;
   flex: 1 1 auto;
   flex-direction: column;
+  height: 100%;
   min-height: 0;
-  overflow: auto;
-  padding: 4px 0 8px;
   color: var(--dsw-alias-label-primary);
   font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: 1.5;
 }
 
+/* The header row, its path, and its control are the text preview's
+   (ui-sidebar-documentpreview TextPreview `.header`, `.path`, `.tool`), copied so
+   the two pane types read as one; keep the two sheets in step. TODO: once the
+   artifact and slot surfaces settle, one copy in ui-primitives could serve
+   every pane header.
+
+   One 38px row flush under the pane's strip: the root's path, then reload 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). */
 .header {
   display: flex;
   flex: 0 0 auto;
-  gap: 6px;
+  gap: 4px;
   align-items: center;
-  padding: 4px 10px;
-  color: var(--dsw-alias-label-secondary);
-  font-weight: 500;
+  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-files-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;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.path[data-files-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);
+}
+
+.body {
+  flex: 1 1 auto;
+  min-height: 0;
+  /* The workspace list's 2px scrollbar offset (ui-workspace WorkspaceBrowser
+     `.list`): the bar sits 2px clear of the pane's edge, and the stable gutter
+     stands in for the right padding so the rows keep their inset whether or
+     not the tree overflows. */
+  margin-right: 2px;
+  padding: 8px 0 8px 8px;
+  overflow: auto;
+  scrollbar-gutter: stable;
+}
+
+/* The bar also stops 2px short of the body's ends, matching its 2px edge
+   offset. WebKit-only: the Firefox path has no track to inset. */
+.body::-webkit-scrollbar-track {
+  margin: 2px;
 }
 
 .level {
@@ -28,7 +94,7 @@
 
 /* Every nested level indents by one step; the root level sits under the header. */
 .level .level {
-  padding-left: 14px;
+  padding-left: 18px;
 }
 
 .item {
@@ -42,13 +108,15 @@
   align-items: center;
   width: 100%;
   min-width: 0;
-  padding: 3px 10px;
+  /* Rows abut, so the hover fill is the whole row; the tree's air is this
+     inset, not a gap between rows. */
+  padding: 5px 10px;
   color: inherit;
   font: inherit;
   text-align: left;
   background: transparent;
   border: 0;
-  border-radius: 6px;
+  border-radius: 10px;
   cursor: pointer;
 }
 
@@ -56,16 +124,15 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* The folder glyph rides the tertiary ink, as the workspace tree's does (ui-workspace Rows `.slot`). */
 .icon {
   flex: 0 0 auto;
-  color: var(--dsw-alias-label-secondary);
+  color: var(--dsw-alias-label-tertiary);
 }
 
-/* The document glyph is drawn 24×28; it rides the row at icon height. */
+/* The type's coloured sheet at the folder glyph's 16px; its colour is its own. */
 .fileIcon {
   flex: 0 0 auto;
-  width: 14px;
-  height: 16px;
 }
 
 .name {
@@ -106,23 +173,36 @@
   line-height: 1.6;
 }
 
-/* The header's reload control, pushed to the row's right edge. */
+/* The same icon button as the Sidebar's strip controls above it: a 28px
+   circle around a 15px glyph. */
 .tool {
   display: inline-flex;
-  flex: 0 0 auto;
+  flex: none;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
-  margin-left: auto;
-  padding: 0;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
   color: var(--dsw-alias-label-secondary);
+  line-height: 1;
   background: transparent;
-  border: 0;
-  border-radius: 6px;
+  border: none;
+  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);
 }
+
+/* The folder sheet before the chip's text; the chip's title row centres it
+   on the text's line and spaces it. */
+.titleIcon {
+  flex: none;
+}

+ 57 - 13
packages/client/ui-sidebar-files/src/client/FilesBody.tsx

@@ -5,19 +5,20 @@
  * for goes through its injected face. The component itself only decides what to
  * draw for each absolute path and what a click means: a directory toggles, a
  * file opens through the owner's `tabActions` for a `file:` viewer to claim, and
- * anything else is shown but refuses to open. The header row carries the one
- * control: reload, which drops every listed level and asks again for the
- * expanded ones.
+ * anything else is shown but refuses to open. The header row is the text
+ * preview's: the root's path, directories greyed and the last segment in full
+ * ink, then the one control at its end, reload, which drops every listed level
+ * and asks again for the expanded ones.
  */
-import { useEffect } from 'react'
-import type { ReactNode } from 'react'
+import { useEffect, useLayoutEffect, useRef } from 'react'
+import type { ReactNode, RefObject } from 'react'
 import clsx from 'clsx'
 import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
 import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  DocumentFileIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16,
+  FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import { fileAddressFor, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
+import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
 import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
 import { childPath } from './face.ts'
 import type { FilesInjected } from './face.ts'
@@ -66,6 +67,39 @@ export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailu
   }
 }
 
+/* jscpd:ignore-start -- the header row is the document preview's (ui-sidebar-documentpreview
+   TextPreview `usePathClipped`), copied because a plugin bundle shares runtime code
+   only through the platform modules. TODO: once the artifact and slot surfaces
+   settle, one copy in ui-primitives could serve every pane header. */
+/**
+ * Keep the path row's `data-files-path-clipped` current: set while the path's
+ * text is wider than its box, so the stylesheet fades the clipped start. Read
+ * after each commit that can change the path or mount the header, and whenever
+ * either box resizes; written to the DOM directly because it changes only how
+ * the stylesheet fades what is already rendered.
+ */
+function usePathClipped(
+  box: RefObject<HTMLDivElement | null>,
+  text: RefObject<HTMLSpanElement | null>,
+  path: string | undefined,
+): 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.filesPathClipped = ''
+      else delete outer.dataset.filesPathClipped
+    }
+    apply()
+    const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
+    observer?.observe(outer)
+    observer?.observe(inner)
+    return () => { observer?.disconnect() }
+  }, [box, text, path])
+}
+/* jscpd:ignore-end */
+
 /** What every level shares: the tab's tree and the two gestures. */
 interface TreeContext {
   readonly state: FilesTabState
@@ -93,7 +127,7 @@ function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirect
     return (
       <li className={css.item} data-files-entry="file" data-files-path={path}>
         <button type="button" className={css.row} onClick={() => { tree.onOpen(path) }}>
-          <DocumentFileIcon className={css.fileIcon} />
+          <FileTypeIcon kind={classifyFileType(entry.name)} size={16} className={css.fileIcon} />
           <span className={css.name}>{entry.name}</span>
         </button>
       </li>
@@ -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<HTMLDivElement>(null)
+  const pathTextRef = useRef<HTMLSpanElement>(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 (
     <div className={css.root} data-files-state="tree" data-files-root={state.root}>
+      {/* jscpd:ignore-start -- the text preview's header row; see `usePathClipped`. */}
       <div className={css.header}>
-        <IconFolderOpen16 className={css.icon} />
-        <span className={css.name}>{title}</span>
+        <div ref={pathRef} className={css.path} title={state.root} data-files-path>
+          <span ref={pathTextRef} className={css.pathText}>
+            {directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
+            <span className={css.pathName}>{name}</span>
+          </span>
+        </div>
         <button
           type="button"
           className={css.tool}
@@ -186,7 +227,10 @@ export function FilesBody({
           <IconRefreshOutline16 />
         </button>
       </div>
-      <ul className={css.level}><Level path={state.root} tree={tree} /></ul>
+      {/* jscpd:ignore-end */}
+      <div className={css.body}>
+        <ul className={css.level}><Level path={state.root} tree={tree} /></ul>
+      </div>
     </div>
   )
 }

+ 37 - 0
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 (
+    <svg width="16" height="16" viewBox="0 0 28 28" fill="none" aria-hidden="true" className={css.titleIcon}>
+      <path
+        d="M1.40234 9.66489C1.40234 8.89627 1.40234 8.51197 1.46125 8.15149C1.61126 7.2335 2.04206 6.38455 2.69437 5.72144C2.95052 5.46106 3.26068 5.23414 3.88101 4.78031C4.20493 4.54333 4.36689 4.42484 4.53696 4.32686C4.96871 4.07811 5.4474 3.9217 5.94272 3.86753C6.13784 3.84619 6.33851 3.84619 6.73986 3.84619H11.3625C12.2767 3.84619 12.7338 3.84619 13.1688 3.95634C13.3131 3.99288 13.455 4.03833 13.5937 4.09244C14.0117 4.25555 14.3837 4.52115 15.1278 5.05235L16.5476 6.06601C16.8731 6.29841 17.0359 6.41461 17.2187 6.48597C17.2794 6.50964 17.3415 6.52953 17.4046 6.54551C17.5949 6.5937 17.7949 6.5937 18.1949 6.5937H20.5273C23.0584 6.5937 24.3239 6.5937 25.2111 7.23827C25.4976 7.44644 25.7496 7.69841 25.9578 7.98493C26.6023 8.8721 26.6023 10.1376 26.6023 12.6687V19.2466C26.6023 21.7777 26.6023 23.0432 25.9578 23.9304C25.7496 24.2169 25.4976 24.4688 25.2111 24.677C24.3239 25.3216 23.0584 25.3216 20.5273 25.3216H7.47734C4.94627 25.3216 3.68074 25.3216 2.79357 24.677C2.50705 24.4688 2.25508 24.2169 2.04691 23.9304C1.40234 23.0432 1.40234 21.7777 1.40234 19.2466V9.66489Z"
+        fill="#F7AD31"
+      />
+      <path d="M5.35156 12.8799H22.6483" stroke="white" strokeWidth="2.6775" />
+    </svg>
+  )
+}
+
+/**
+ * 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 (
+    <>
+      <FolderGlyph />
+      {tab.title}
+    </>
+  )
+}

+ 0 - 1
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,
     }],
   }

+ 10 - 4
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')
 }

+ 3 - 5
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.',

+ 4 - 2
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')

+ 0 - 1
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()
   })
 

+ 57 - 5
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<HTMLElement>('[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 }))

+ 22 - 0
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(<FilesTitle {...props('文件')} />)
+    const svg = container.querySelector('svg')
+    expect(svg?.getAttribute('width')).toBe('16')
+    expect(svg?.getAttribute('aria-hidden')).toBe('true')
+    expect(container.textContent).toBe('文件')
+  })
+})

+ 2 - 2
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

+ 1 - 1
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
 

+ 1 - 1
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,也不持有运行时状态。
 
 ## 目录
 

+ 16 - 0
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'
 
 /**

+ 14 - 1
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: '/' })
+  })
 })