From b24ebc8cce461e6df86413ac9cff8df0f0966e89 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 7 Sep 2026 21:23:17 +0800
Subject: [PATCH] feat(sidebar-files): add lazy workspace file tree tabs
---
packages/client/ui-sidebar-files/README.md | 72 +++++++
packages/client/ui-sidebar-files/README.zh.md | 72 +++++++
packages/client/ui-sidebar-files/package.json | 76 +++++++
.../src/client/FilesBody.module.css | 128 ++++++++++++
.../ui-sidebar-files/src/client/FilesBody.tsx | 192 ++++++++++++++++++
.../ui-sidebar-files/src/client/definition.ts | 37 ++++
.../ui-sidebar-files/src/client/face.ts | 149 ++++++++++++++
.../ui-sidebar-files/src/client/index.ts | 52 +++++
.../ui-sidebar-files/src/client/locales.ts | 56 +++++
.../ui-sidebar-files/src/client/store.ts | 165 +++++++++++++++
.../ui-sidebar-files/src/css-modules.d.ts | 6 +
packages/client/ui-sidebar-files/src/index.ts | 4 +
.../client/ui-sidebar-files/tsconfig.json | 48 +++++
.../client/ui-sidebar-files/tsdown.config.ts | 3 +
14 files changed, 1060 insertions(+)
create mode 100644 packages/client/ui-sidebar-files/README.md
create mode 100644 packages/client/ui-sidebar-files/README.zh.md
create mode 100644 packages/client/ui-sidebar-files/package.json
create mode 100644 packages/client/ui-sidebar-files/src/client/FilesBody.module.css
create mode 100644 packages/client/ui-sidebar-files/src/client/FilesBody.tsx
create mode 100644 packages/client/ui-sidebar-files/src/client/definition.ts
create mode 100644 packages/client/ui-sidebar-files/src/client/face.ts
create mode 100644 packages/client/ui-sidebar-files/src/client/index.ts
create mode 100644 packages/client/ui-sidebar-files/src/client/locales.ts
create mode 100644 packages/client/ui-sidebar-files/src/client/store.ts
create mode 100644 packages/client/ui-sidebar-files/src/css-modules.d.ts
create mode 100644 packages/client/ui-sidebar-files/src/index.ts
create mode 100644 packages/client/ui-sidebar-files/tsconfig.json
create mode 100644 packages/client/ui-sidebar-files/tsdown.config.ts
diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md
new file mode 100644
index 0000000000..6e9e305b15
--- /dev/null
+++ b/packages/client/ui-sidebar-files/README.md
@@ -0,0 +1,72 @@
+---
+description: "The right Sidebar's file-tree tab type for the dsh web client: the session workspace root listed one level at a time over the wire, opening files into the Sidebar by resource address."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-files
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The right Sidebar's navigator tab type: the session's workspace root as a tree, listed one level at a time over the wire, opening files into the Sidebar. It is a page type reached from the guide and claims no address; it opens files by address for the `dsh-resource://file` viewers to claim — nothing in `ui-sidebar-right` knows this package.
+
+## Table of Contents
+
+- [What it registers](#what-it-registers)
+- [The tree](#the-tree)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+
+## 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.
+
+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).
+
+
+## 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.
+
+| Entry type | Row |
+|---|---|
+| `directory` | Toggles; the level is fetched the first time it opens and kept while collapsed. |
+| `file` | Opens `dsh-resource://file/session//`, built by `fileAddressFor` from `@deepseek-ai/dsh-util-workspace-path` from the entry's absolute path and the tree's root, through `useTabInfo().tab.actions.openResource`, landing in the tab's own pane. |
+| `other` | Shown greyed and not clickable, so the directory is reported whole. |
+
+A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload drops every listed level and asks again for the expanded ones; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree.
+
+State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), and `expanded`. The owner's `signal` ends a bucket: on abort the tab is forgotten and a listing that settles afterwards writes nothing.
+
+
+## Model Experience
+
+None, as this package draws a workspace file tree in the browser and registers nothing model-facing.
+
+#### KV Cache effect
+
+None; directory listings travel over the Remote and assemble no model request.
+
+## Known Limitations and Deferred Work
+
+
+- **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, current-file highlight, or filesystem watching; a level changes only through reload.
+- **One root.** The tree is rooted at the session's working directory; there is no way to browse above it, and the Host refuses paths outside the workspace root anyway.
+
+
+### Dev Note
+
+
+Working context for maintainers — click to expand
+
+None.
+
+
+
+**Runtime invariant:** No companion is published. The tree's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against.
diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md
new file mode 100644
index 0000000000..95a1544418
--- /dev/null
+++ b/packages/client/ui-sidebar-files/README.zh.md
@@ -0,0 +1,72 @@
+---
+description: "dsh Web 客户端右侧 Sidebar 的文件树 tab 类型:逐层经线上列出会话工作区根目录,按资源地址把文件打开到 Sidebar。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-files
+
+[English](README.md) | 中文
+
+## 概述
+
+右侧 Sidebar 的导航器 tab 类型:把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里。它是从引导页进入的页类型,不认领任何地址;它按地址打开文件,交给 `dsh-resource://file` 的查看器认领:`ui-sidebar-right` 里没有任何东西认识本包。
+
+## 目录
+
+- [注册了什么](#what-it-registers)
+- [树](#the-tree)
+- [模型体验](#model-experience)
+- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+
+## 注册了什么
+
+- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。
+- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:树本身,以及它唯一的控件、位于标题行右端的重新读取。
+
+`src/client/` 下六个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`locales.ts`(它说什么)、`index.ts`(接线)。
+
+
+## 树
+
+根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标签由 `@deepseek-ai/dsh-util-workspace-path` 的 `workspaceTitleOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
+
+| 条目类型 | 行 |
+|---|---|
+| `directory` | 切换展开与折叠;该层在首次打开时拉取,折叠期间保留。 |
+| `file` | 经 `useTabInfo().tab.actions.openResource` 打开 `dsh-resource://file/session//`,地址由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 从条目的绝对路径与树的根生成,落在该 tab 自己的 pane 里。 |
+| `other` | 灰显且不可点击,使目录被完整报告。 |
+
+被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
+
+状态住在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)与 `expanded`。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表什么也不写。
+
+
+## 模型体验
+
+无,因为本包在浏览器里绘制工作区文件树,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+无;目录列表经 Remote 传输,不会组装模型请求。
+
+## 已知限制与暂缓事项
+
+
+- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。
+- **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。
+
+
+### 开发备注
+
+
+维护者工作上下文——点击展开
+
+无。
+
+
+
+**运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。
diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json
new file mode 100644
index 0000000000..0ba064054e
--- /dev/null
+++ b/packages/client/ui-sidebar-files/package.json
@@ -0,0 +1,76 @@
+{
+ "name": "@deepseek-ai/dsh-client-ui-sidebar-files",
+ "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar",
+ "version": "0.1.3-alpha.2",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/client/ui-sidebar-files"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./client": {
+ "types": "./lib/types/client/index.d.ts",
+ "default": "./lib/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-api-workspace-files",
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
+ "@deepseek-ai/dsh-client-ui-session",
+ "@deepseek-ai/dsh-api-remotes"
+ ],
+ "platform": "web"
+ }
+ },
+ "scripts": {
+ "bundle": "tsdown",
+ "watch": "tsdown --watch"
+ },
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-api-remotes": "workspace:^",
+ "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
+ "@deepseek-ai/dsh-client-locale": "workspace:^",
+ "@deepseek-ai/dsh-client-store": "workspace:^",
+ "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
+ "@testing-library/react": "^16.1.0",
+ "@types/react": "~18.3.1",
+ "@types/react-dom": "~18.3.0"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/client.js",
+ "lib/types/**/*.d.ts"
+ ]
+}
diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css
new file mode 100644
index 0000000000..4ae79aa58a
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css
@@ -0,0 +1,128 @@
+.root {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ 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;
+}
+
+.header {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 6px;
+ align-items: center;
+ padding: 4px 10px;
+ color: var(--dsw-alias-label-secondary);
+ font-weight: 500;
+}
+
+.level {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+/* Every nested level indents by one step; the root level sits under the header. */
+.level .level {
+ padding-left: 14px;
+}
+
+.item {
+ margin: 0;
+ padding: 0;
+}
+
+.row {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ width: 100%;
+ min-width: 0;
+ padding: 3px 10px;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.row:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.icon {
+ flex: 0 0 auto;
+ color: var(--dsw-alias-label-secondary);
+}
+
+/* The document glyph is drawn 24×28; it rides the row at icon height. */
+.fileIcon {
+ flex: 0 0 auto;
+ width: 14px;
+ height: 16px;
+}
+
+.name {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+/* Neither a file nor a directory: shown so the directory is reported whole,
+ greyed so nobody tries to open it. */
+.other {
+ color: var(--dsw-alias-label-tertiary);
+ cursor: default;
+}
+
+.other:hover {
+ background: transparent;
+}
+
+.note {
+ margin: 0;
+ padding: 3px 10px;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 12px;
+}
+
+.status {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 10px;
+}
+
+.statusLine {
+ margin: 0;
+ color: var(--dsw-alias-label-secondary);
+ font-size: var(--dsh-content-font-size-secondary, 13px);
+ line-height: 1.6;
+}
+
+/* The header's reload control, pushed to the row's right edge. */
+.tool {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ margin-left: auto;
+ padding: 0;
+ color: var(--dsw-alias-label-secondary);
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.tool:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
new file mode 100644
index 0000000000..46727638b7
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx
@@ -0,0 +1,192 @@
+/**
+ * The file tree's body: the session's workspace root, listed one level at a time.
+ *
+ * Everything the tree keeps lives in its store, keyed by tab; everything it asks
+ * 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.
+ */
+import { useEffect } from 'react'
+import type { ReactNode } 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,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import { fileAddressFor, workspaceTitleOf } 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'
+import type {} from './locales.ts'
+import type { FilesTabState, createFilesStore } from './store.ts'
+import css from './FilesBody.module.css'
+
+/** The body's composed props: the tab it draws, its store, its face, and its copy. */
+export type FilesBodyProps =
+ & PropsRuntime<'sidebar.right.pane.tab'>
+ & PropsStore>
+ & FilesInjected
+ & PropsLocale<'sidebarFiles'>
+
+/** Natural, case-insensitive name order, so `file2` precedes `file10`. */
+const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
+
+/**
+ * Order one level's entries for display: directories first, then everything
+ * else, each group by name. The endpoint's order is a listing fact; this is the
+ * reader's.
+ * @param entries - the listing as the endpoint returned it.
+ * @returns a new array, directories first, then by name within each group.
+ */
+export function orderEntries(entries: readonly WorkspaceDirectoryEntry[]): WorkspaceDirectoryEntry[] {
+ return [...entries].sort((left, right) => {
+ const group = Number(right.type === 'directory') - Number(left.type === 'directory')
+ return group !== 0 ? group : byName.compare(left.name, right.name)
+ })
+}
+
+/**
+ * Say why a directory could not be listed, in terms of the directory.
+ * @param t - namespace-bound translate.
+ * @param failure - the settled Remote failure.
+ * @returns the line to show under the directory.
+ */
+export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailure): string {
+ switch (failure.code) {
+ case 'workspace-file/not-found': return t('error.notFound')
+ case 'workspace-file/outside-workspace': return t('error.outsideWorkspace')
+ case 'workspace-file/not-directory': return t('error.notDirectory')
+ // Carrier and unclassified host failures reach the reader as themselves:
+ // this tree knows nothing useful to add to a transport-level message.
+ default: return t('error.unavailable', { message: failure.message })
+ }
+}
+
+/** What every level shares: the tab's tree and the two gestures. */
+interface TreeContext {
+ readonly state: FilesTabState
+ readonly onToggle: (path: string) => void
+ readonly onOpen: (path: string) => void
+ readonly t: TranslateNS<'sidebarFiles'>
+}
+
+/** One entry's row, and its children when it is an expanded directory. */
+function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirectoryEntry; tree: TreeContext }): ReactNode {
+ const path = childPath(parent, entry.name)
+ if (entry.type === 'directory') {
+ const expanded = tree.state.expanded.includes(path)
+ return (
+
}
+ >
+ )
+}
+
+/** The file tree's body: the workspace root and whatever the reader has opened under it. */
+export function FilesBody({
+ useTabInfo, sessionId, useSessions, useStore, actions, start, load, toggle, t,
+}: FilesBodyProps): ReactNode {
+ const { tab } = useTabInfo()
+ const { signal, actions: tabActions } = tab
+ const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd)
+ const state = useStore(store => store.byTab[tab.id])
+ useEffect(() => {
+ // A bucket gone because the record aborted must not be re-seeded by a
+ // component that has not unmounted yet.
+ if (state !== undefined || cwd === undefined || signal.aborted) return
+ start(tab.id, cwd, signal)
+ }, [state, cwd, tab.id, signal, start])
+
+ if (cwd === undefined) {
+ return (
+
+
{t('noWorkspace')}
+
+ )
+ }
+ if (state === undefined) return null
+ const tree: TreeContext = {
+ state,
+ onToggle: (path) => { toggle(tab.id, path, state.levels[path] !== undefined, signal) },
+ // Every row is under the tree's root, so its address is session-relative.
+ onOpen: (path) => { tabActions.openResource(fileAddressFor(sessionId, state.root, path)) },
+ t,
+ }
+ // Reload drops every level and asks again for the expanded ones; a collapsed
+ // level is fetched again the next time it opens.
+ const reload = (): void => {
+ 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
+ return (
+
+
+
+ {title}
+
+
+
+
+ )
+}
diff --git a/packages/client/ui-sidebar-files/src/client/definition.ts b/packages/client/ui-sidebar-files/src/client/definition.ts
new file mode 100644
index 0000000000..f967a472a0
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/client/definition.ts
@@ -0,0 +1,37 @@
+/**
+ * Stage one of this package's registration: what the `files` tab type IS.
+ *
+ * The type is a page, not a viewer: it claims no address. The guide page offers
+ * it as an entry box, and the tree opens files through `tabActions.openResource`
+ * for the `dsh-resource://file` viewers to claim.
+ */
+import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import type {} from './locales.ts'
+import { IconFolderClose16 } from '@deepseek-ai/dsh-client-ui-primitives'
+
+/** The tab kind this package owns. */
+export const FILES_KIND = 'files'
+
+/** This implementation's identity in the tab system, and the key its body registers under. */
+export const FILES_ID = '@deepseek-ai/dsh-client-ui-sidebar-files'
+
+/**
+ * The files type's registry definition.
+ * @param t - namespace-bound translate, read fresh on every label call.
+ * @returns the definition to register.
+ */
+export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTabDefinition {
+ return {
+ id: FILES_ID,
+ kind: FILES_KIND,
+ priority: 'builtin',
+ title: () => t('type.label'),
+ guide: [{
+ order: 10,
+ title: () => t('guide.title'),
+ description: () => t('guide.description'),
+ icon: IconFolderClose16,
+ }],
+ }
+}
diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts
new file mode 100644
index 0000000000..a170e897ce
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/client/face.ts
@@ -0,0 +1,149 @@
+/**
+ * The tree's asynchronous half: listing directories into the store.
+ *
+ * The component never awaits anything. It calls `start` / `load` / `toggle`, and
+ * this face performs the listing and writes the outcome through the store's own
+ * actions — the Slot-standard `inject` shape, so the session id is resolved by
+ * the framework and the write set stays the store's.
+ *
+ * The listing itself is bound here to the Client Remote face: the tree keys
+ * every level by absolute path and hands the endpoint that same absolute path;
+ * the endpoint answers with the directory's workspace-relative path as well,
+ * which the tree has no use for and drops.
+ *
+ * One level has one listing in force: asking for a level again — the reload
+ * gesture, a directory reopened after a reset — retires the listing still in
+ * flight for it, whose settlement then writes nothing. Cleanup rides the owner's
+ * `signal`: a request is not made for a record that already ended, and when the
+ * record goes away the bucket and the tab's listing bookkeeping are forgotten,
+ * so no later settlement writes to it.
+ */
+import type { ClientRemote, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
+import type { BoundActions } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { DirLevel, createFilesStore } from './store.ts'
+
+/**
+ * One directory listing, bound to a Remote face.
+ *
+ * The session travels with the call because the endpoint resolves the workspace
+ * root from it: the same path means different directories in different sessions.
+ * A Remote call does not reject — the result carries the failure.
+ */
+export type ListWorkspaceDirectory = (
+ sessionId: SessionId,
+ path: string,
+ signal: AbortSignal,
+) => Promise>
+
+/**
+ * The slice of the Client Remote face this package calls: the `workspaceFiles`
+ * namespace's `list`, exactly as the Host's generated client declares it.
+ */
+export type WorkspaceFilesListRemote = {
+ readonly workspaceFiles: Pick
+}
+
+/**
+ * Bind the listing to one Remote face, keeping only what the tree stores.
+ * @param remote - the Client Remote face carrying the `workspaceFiles` namespace.
+ * @returns the listing the tree's face performs.
+ */
+export function createList(remote: WorkspaceFilesListRemote): ListWorkspaceDirectory {
+ return async (sessionId, path, signal) => {
+ const result = await remote.workspaceFiles.list(sessionId, path, signal)
+ if (!result.ok) return result
+ return { ok: true, value: { entries: result.value.entries, truncated: result.value.truncated } }
+ }
+}
+
+/**
+ * The absolute path of one child entry.
+ *
+ * Joined with `/` whatever the parent's separators: the Host resolves mixed
+ * separators, and the tree only needs a stable key.
+ * @param parent - absolute path of the listed directory.
+ * @param name - the entry's basename.
+ * @returns the child's absolute path.
+ */
+export function childPath(parent: string, name: string): string {
+ return `${parent.replace(/[/\\]+$/, '')}/${name}`
+}
+
+/** The tree's injected business face, as the body receives it. */
+export interface FilesInjected {
+ /**
+ * Seed this tab's tree and list its root.
+ * @param tabId - the tab being drawn.
+ * @param root - absolute path of the workspace root.
+ * @param signal - the tab record's lifetime.
+ */
+ readonly start: (tabId: TabId, root: string, signal: AbortSignal) => void
+ /**
+ * List one directory into the store.
+ * @param tabId - the tab being drawn.
+ * @param path - absolute directory path.
+ * @param signal - the tab record's lifetime.
+ */
+ readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void
+ /**
+ * Open or collapse one directory, listing it the first time it opens.
+ * @param tabId - the tab being drawn.
+ * @param path - absolute directory path.
+ * @param loaded - whether this level already has state.
+ * @param signal - the tab record's lifetime.
+ */
+ readonly toggle: (tabId: TabId, path: string, loaded: boolean, signal: AbortSignal) => void
+}
+
+/**
+ * Bind the tree's face to one directory listing.
+ * @param list - the bound `workspaceFiles.list` call.
+ * @returns the Slot `inject` factory: session and bound actions in, face out.
+ */
+export function filesFace(
+ list: ListWorkspaceDirectory,
+): (sessionId: SessionId, actions: BoundActions>) => FilesInjected {
+ return (
+ sessionId: SessionId,
+ actions: BoundActions>,
+ ): FilesInjected => {
+ /** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */
+ const generations = new Map>()
+ const nextGeneration = (tabId: TabId, path: string): number => {
+ const byPath = generations.get(tabId) ?? new Map()
+ generations.set(tabId, byPath)
+ const generation = (byPath.get(path) ?? 0) + 1
+ byPath.set(path, generation)
+ return generation
+ }
+ const load = (tabId: TabId, path: string, signal: AbortSignal): void => {
+ if (signal.aborted) return
+ const generation = nextGeneration(tabId, path)
+ actions.loading(tabId, path)
+ void list(sessionId, path, signal).then((result) => {
+ // A newer listing of this level was asked for since, or the record is
+ // gone and its bookkeeping with it: nothing left for this one to write.
+ if (generations.get(tabId)?.get(path) !== generation) return
+ if (result.ok) actions.loaded(tabId, path, result.value)
+ else actions.failed(tabId, path, result.error)
+ })
+ }
+ return {
+ start(tabId, root, signal) {
+ actions.start(tabId, root)
+ signal.addEventListener('abort', () => {
+ generations.delete(tabId)
+ actions.forget(tabId)
+ }, { once: true })
+ load(tabId, root, signal)
+ },
+ load,
+ toggle(tabId, path, loaded, signal) {
+ actions.toggled(tabId, path)
+ if (!loaded) load(tabId, path, signal)
+ },
+ }
+ }
+}
diff --git a/packages/client/ui-sidebar-files/src/client/index.ts b/packages/client/ui-sidebar-files/src/client/index.ts
new file mode 100644
index 0000000000..0f04544729
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/client/index.ts
@@ -0,0 +1,52 @@
+/**
+ * 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 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.
+ */
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+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 { en, zh } from './locales.ts'
+import { createFilesStore } from './store.ts'
+
+export type { SidebarFilesKey } from './locales.ts'
+export type { DirLevel, FilesState, FilesTabState, LevelState } from './store.ts'
+export type { FilesInjected, ListWorkspaceDirectory, WorkspaceFilesListRemote } from './face.ts'
+export type { FilesBodyProps } from './FilesBody.tsx'
+
+/** This package's copy namespace. */
+const NS = 'sidebarFiles'
+
+/**
+ * Required browser services: the tab registry, the keyed seat, the Remote
+ * carrier and its namespace, and copy.
+ */
+export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
+
+/**
+ * Client plugin body: register the type, its dictionaries, then its body.
+ * @param ctx - client root context carrying the registry, the slots, and the Remote face.
+ */
+export function apply(ctx: ClientContext): void {
+ const t = ctx.locale.bind(NS)
+ ctx.effect(() => ctx.sidebarRightTabs.register(filesDefinition(t)), 'ui-sidebar-files: files type')
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-files: dictionaries')
+
+ const store = createFilesStore()
+ const inject = filesFace(createList(ctx.remote))
+ ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
+ { name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject },
+ FilesBody,
+ )), 'ui-sidebar-files: files tab body')
+}
diff --git a/packages/client/ui-sidebar-files/src/client/locales.ts b/packages/client/ui-sidebar-files/src/client/locales.ts
new file mode 100644
index 0000000000..1976add093
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/client/locales.ts
@@ -0,0 +1,56 @@
+/**
+ * `sidebarFiles` namespace dictionaries, and the namespace's declaration.
+ *
+ * The failure lines name what the tree could not list, one code each, because a
+ * directory that is gone, one outside the workspace, and a path that is not a
+ * directory each suggest a different next step.
+ *
+ * The namespace merge lives with its key set so that any module naming
+ * `TranslateNS<'sidebarFiles'>` or `PropsLocale<'sidebarFiles'>` needs only this
+ * file, whichever entry a program loads first.
+ */
+import type {} from '@deepseek-ai/dsh-client-ui-slots'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+ interface LocaleNamespaceMap {
+ /** File-tree type name, guide entry, row states, and failure lines. */
+ sidebarFiles: SidebarFilesKey
+ }
+}
+
+/** Simplified Chinese dictionary and key-set source of truth. */
+export const zh = {
+ 'type.label': '文件',
+ 'guide.title': '文件',
+ 'guide.description': '浏览这个会话工作区里的文件,点开就能查看。',
+ loading: '正在读取…',
+ empty: '空目录',
+ truncated: '条目太多,只显示了一部分。',
+ noWorkspace: '这个会话没有工作区目录。',
+ reload: '重新读取',
+ 'entry.other': '这不是文件或目录,没法打开。',
+ 'error.notFound': '这个目录不在了。可能已被移动或删除。',
+ 'error.outsideWorkspace': '这个目录在工作区之外,侧栏不会读取它。',
+ 'error.notDirectory': '这不是一个目录。',
+ 'error.unavailable': '读取失败:{message}',
+} satisfies Record
+
+/** Files dictionary key union. */
+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.',
+ loading: 'Reading…',
+ empty: 'Empty directory',
+ 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.',
+ 'error.notFound': 'That directory is gone. It may have been moved or deleted.',
+ 'error.outsideWorkspace': 'That directory is outside the workspace, so the sidebar will not read it.',
+ 'error.notDirectory': 'That is not a directory.',
+ 'error.unavailable': 'Read failed: {message}',
+} satisfies Record
diff --git a/packages/client/ui-sidebar-files/src/client/store.ts b/packages/client/ui-sidebar-files/src/client/store.ts
new file mode 100644
index 0000000000..619ed0ba30
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/client/store.ts
@@ -0,0 +1,165 @@
+/**
+ * The file tree's view state: which directories are expanded, and what each
+ * loaded level contains.
+ *
+ * The tree is not one resource. A directory listing per level, expanded lazily,
+ * is state the type owns — so it lives in a Slot-standard exclusive store
+ * (one instance per session), bucketed by tab id because two tabs of this kind
+ * in one session expand independently.
+ *
+ * Writers run between `start` and `forget`: the owner's `signal` is what ends a
+ * bucket's life, and the face stops dispatching once it aborts.
+ */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
+
+/**
+ * One directory's contents, as one expanded level of the tree.
+ *
+ * The endpoint's listing also names the directory as a workspace-relative path;
+ * the tree keys every level by absolute path instead, so the adapter drops it.
+ */
+export interface DirLevel {
+ /** The directory's entries, in the endpoint's order. */
+ readonly entries: readonly WorkspaceDirectoryEntry[]
+ /** The listing hit the endpoint's entry cap, so entries are missing. */
+ readonly truncated: boolean
+}
+
+/** What one directory level is doing right now. */
+export type LevelState =
+ | { readonly kind: 'loading' }
+ | { readonly kind: 'ready'; readonly level: DirLevel }
+ | { readonly kind: 'failed'; readonly failure: RemoteFailure }
+
+/**
+ * One tab's tree: its root, the levels it has asked for, and what is open.
+ *
+ * Every path here is absolute: the root is the session's working directory as
+ * the Host reports it, and a child is the parent joined with the entry name.
+ */
+export interface FilesTabState {
+ /** Absolute path of the workspace root this tree is rooted at. */
+ root: string
+ /** Level state by absolute directory path; a path absent here was never asked for. */
+ levels: Record
+ /** Expanded absolute directory paths, root included. */
+ expanded: string[]
+}
+
+/** Every tab's tree, keyed by tab id. */
+export interface FilesState {
+ byTab: Record
+}
+
+/**
+ * One tab's bucket, which every writer after `start` relies on: the face only
+ * dispatches while the record's signal is live, and `forget` runs on its abort.
+ * @param state - the draft.
+ * @param tabId - the tab being written.
+ * @returns the tab's tree.
+ */
+function bucket(state: FilesState, tabId: TabId): FilesTabState {
+ const tree = state.byTab[tabId]
+ if (tree === undefined) throw new Error(`ui-sidebar-files: no tree for tab "${tabId}"`)
+ return tree
+}
+
+/** The tree store's write set; every action names the tab it writes. */
+type FilesActions = {
+ start: (draft: FilesState, tabId: TabId, root: string) => void
+ loading: (draft: FilesState, tabId: TabId, path: string) => void
+ loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
+ failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void
+ toggled: (draft: FilesState, tabId: TabId, path: string) => void
+ reset: (draft: FilesState, tabId: TabId) => void
+ forget: (draft: FilesState, tabId: TabId) => void
+}
+
+/**
+ * Declare the file tree's store.
+ *
+ * A factory rather than a shared handle: the registration declares it as an
+ * exclusive store, so the framework mints one instance per session.
+ * @returns the store handle to declare on the registration.
+ */
+export function createFilesStore(): EngineStoreHandle {
+ return defineStore({
+ init: (): FilesState => ({ byTab: {} }),
+ actions: {
+ /**
+ * Seed one tab's tree at its workspace root, with the root expanded.
+ * @param d - draft state.
+ * @param tabId - the tab being drawn.
+ * @param root - absolute path of the workspace root.
+ */
+ start: (d, tabId: TabId, root: string) => {
+ d.byTab[tabId] = { root, levels: {}, expanded: [root] }
+ },
+ /**
+ * Mark one directory as being listed.
+ * @param d - draft state.
+ * @param tabId - the tab being drawn.
+ * @param path - absolute directory path.
+ */
+ loading: (d, tabId: TabId, path: string) => {
+ bucket(d, tabId).levels[path] = { kind: 'loading' }
+ },
+ /**
+ * Record one directory's contents.
+ * @param d - draft state.
+ * @param tabId - the tab being drawn.
+ * @param path - absolute directory path.
+ * @param level - the listing to show under it.
+ */
+ loaded: (d, tabId: TabId, path: string, level: DirLevel) => {
+ bucket(d, tabId).levels[path] = { kind: 'ready', level }
+ },
+ /**
+ * Record why one directory could not be listed.
+ * @param d - draft state.
+ * @param tabId - the tab being drawn.
+ * @param path - absolute directory path.
+ * @param failure - the settled Remote failure.
+ */
+ failed: (d, tabId: TabId, path: string, failure: RemoteFailure) => {
+ bucket(d, tabId).levels[path] = { kind: 'failed', failure }
+ },
+ /**
+ * Open a collapsed directory, or collapse an open one.
+ *
+ * A collapsed level keeps what it loaded, so reopening it draws at once.
+ * @param d - draft state.
+ * @param tabId - the tab being drawn.
+ * @param path - absolute directory path.
+ */
+ toggled: (d, tabId: TabId, path: string) => {
+ const state = bucket(d, tabId)
+ const at = state.expanded.indexOf(path)
+ if (at >= 0) state.expanded.splice(at, 1)
+ else state.expanded.push(path)
+ },
+ /**
+ * Drop every loaded level, keeping what is expanded.
+ *
+ * This is the reload gesture's first half: the expanded set says which
+ * levels to fetch again.
+ * @param d - draft state.
+ * @param tabId - the tab being drawn.
+ */
+ reset: (d, tabId: TabId) => {
+ bucket(d, tabId).levels = {}
+ },
+ /**
+ * Forget one tab's tree, for a tab record that is gone.
+ * @param d - draft state.
+ * @param tabId - the tab that went away.
+ */
+ forget: (d, tabId: TabId) => {
+ d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId))
+ },
+ },
+ })
+}
diff --git a/packages/client/ui-sidebar-files/src/css-modules.d.ts b/packages/client/ui-sidebar-files/src/css-modules.d.ts
new file mode 100644
index 0000000000..bc5e482353
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/css-modules.d.ts
@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+ const classes: Record
+ export default classes
+}
+
+declare module '*.css'
diff --git a/packages/client/ui-sidebar-files/src/index.ts b/packages/client/ui-sidebar-files/src/index.ts
new file mode 100644
index 0000000000..154525a7cb
--- /dev/null
+++ b/packages/client/ui-sidebar-files/src/index.ts
@@ -0,0 +1,4 @@
+/** Pure host half; the whole tab type lives in the browser export. */
+
+/** Host plugin body: the file tree contributes nothing to the host tree. */
+export function apply(): void {}
diff --git a/packages/client/ui-sidebar-files/tsconfig.json b/packages/client/ui-sidebar-files/tsconfig.json
new file mode 100644
index 0000000000..97ba9441ec
--- /dev/null
+++ b/packages/client/ui-sidebar-files/tsconfig.json
@@ -0,0 +1,48 @@
+{
+ "extends": "../../../tsconfig.base.client.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../api/remotes/tsconfig.client.json"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../locale"
+ },
+ {
+ "path": "../store"
+ },
+ {
+ "path": "../ui-dockkit"
+ },
+ {
+ "path": "../ui-primitives"
+ },
+ {
+ "path": "../ui-session"
+ },
+ {
+ "path": "../ui-sidebar-right"
+ },
+ {
+ "path": "../ui-slots"
+ },
+ {
+ "path": "../../util/workspace-path"
+ },
+ {
+ "path": "../../api/workspace-files/tsconfig.client.json"
+ }
+ ]
+}
diff --git a/packages/client/ui-sidebar-files/tsdown.config.ts b/packages/client/ui-sidebar-files/tsdown.config.ts
new file mode 100644
index 0000000000..15e759c557
--- /dev/null
+++ b/packages/client/ui-sidebar-files/tsdown.config.ts
@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-files', ['lib/types/index.js'])