Merge pull request #5046 from deepseek-harness/fix/language-neutral-default-workspace-naming

fix(workspace): keep the default Workspace path language-neutral
This commit is contained in:
07akioni authored and GitHub committed 2026-09-24 10:41:07 +08:00
commit 655534d009
53 files changed
+351 -202

No files matched your search

@@ -9,11 +9,11 @@
en: 952ca70e83872f88
zh: 471b4e39dba2039a
/agent-note-first-use-default-workspace/decision:
en: 7f51354b3082f290
zh: 8f6ce1c14f11f888
en: 5f70a77db26ed9e2
zh: 8f6bfb84385315d4
/agent-note-first-use-default-workspace/alternatives-considered:
en: 65c93ecfbbcf22a9
zh: c88ed47852200752
en: 58523eb415e53e2a
zh: 714ddd81ef7d3262
/agent-note-first-use-default-workspace/consequences:
en: ab39d4e9302a2641
zh: 8921bb8451ef2d0e
@@ -14,7 +14,9 @@ Startup waits for complete Workspace and Session baselines. When both lists are
The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently.
The Client resolves the initial directory name and title from its language at startup. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md).
The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md).
The naming half of this decision is superseded: [language-neutral default Workspace naming](2026-09-23-language-neutral-default-workspace-naming.md) fixes the directory name and stored title and localizes only the on-screen label.
Ineligible first use returns no Workspace without a failure dialog. Preparation failure offers the existing folder picker; startup does not retry on later list notifications. A successful registration survives Session creation or prompt failure. Selecting either the default or a manually picked Workspace never submits a message.
@@ -23,7 +25,7 @@ Ineligible first use returns no Workspace without a failure dialog. Preparation
- Creating only when sending avoids unused directories but requires a separate local draft, transfer into a Session, and coordination of the first submission. Startup creation accepts the early filesystem effect and uses ordinary Session input throughout.
- Falling back to `<home>/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override.
- Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions.
- Using the localized path as the initialization marker would allow language changes or deletion to create another default.
- Using the initialization directory's path as the initialization marker would allow renaming, relocation, or deletion to create another default.
- Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments.
## Consequences
@@ -14,7 +14,9 @@ Status: implemented
[Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。
Client 在启动时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。
Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。
本决策中与命名相关的部分已被取代:[语言中立的默认工作区命名](2026-09-23-language-neutral-default-workspace-naming.zh.md)固定目录名与存储标题,只本地化屏幕上的标签。
不符合首次使用条件时不返回 Workspace,也不显示失败弹窗。准备失败时提供现有文件夹选择器;后续列表通知不会触发启动流程重试。成功的登记会在 Session 创建或提示词发送失败后保留。选中默认或手动选择的 Workspace 均不会提交消息。
@@ -23,7 +25,7 @@ Client 在启动时按其语言解析初始目录名和标题。Host 控制器
- 仅在发送时创建可以避免未使用的目录,但需要独立的本地草稿、向 Session 转移草稿及首次提交协调逻辑。启动时创建接受提前写入文件系统的副作用,始终使用普通 Session 输入。
- 系统查询不可用时回退到 `<home>/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。
- 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。
- 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。
- 将初始化目录的路径作为初始化标记,会允许改名、迁移或删除后再次创建默认工作区。
- 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。
## Consequences
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-23-language-neutral-default-workspace-naming.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-23-language-neutral-default-workspace-naming.md
/agent-note-language-neutral-default-workspace-naming:
en: f9065db27c1cd7f9
zh: 3dd1a223a6fcc6c6
/agent-note-language-neutral-default-workspace-naming/problem:
en: 35967bed9e895460
zh: 9cf4da88466ff37c
/agent-note-language-neutral-default-workspace-naming/decision:
en: 72fd5b65bbee5434
zh: 08a4967a1530e686
/agent-note-language-neutral-default-workspace-naming/alternatives-considered:
en: 556df0ed81444162
zh: 5eee5029659419c3
/agent-note-language-neutral-default-workspace-naming/consequences:
en: 5f710ca9cf60d514
zh: 9ea7bdaf8925fd44
@@ -0,0 +1,36 @@
# Agent Note: Language-neutral default Workspace naming
Status: implemented
English | [中文](2026-09-23-language-neutral-default-workspace-naming.zh.md)
## Problem
[First-use default Workspace](2026-09-20-default-workspace.md) let the Client pick both the directory name and the stored title from its startup language: Chinese installations created `默认工作区`, English ones `Default workspace`, everything else `default-workspace`. The name a reader sees and the name written to disk were the same string, so one product decision carried two incompatible requirements. A path is addressed by shell commands, tool arguments, `@path` references, session logs, and backups, and must not depend on which language happened to be active when the installation started. A label is read, and should be in the reader's language. Switching language afterwards satisfied neither: the path was already fixed, and so was the label derived from it.
## Decision
The directory name and the stored title are one fixed, language-neutral string; only the on-screen label follows the reader's language.
`DEFAULT_WORKSPACE_DIRECTORY` (`default-workspace`) is owned by the [Workspace controller](../../../../packages/api/workspace-controller/README.md#first-use-workspace) and published from its own `./default-workspace` subpath — a pure fold with no imports, so client bundles inline it rather than requesting a module-table row the package does not publish. The Host joins it under `<Documents>/deepseek-harness`, and the registry titles the Workspace after the canonical directory's final segment, so the automatic title *is* that same name with no second source to keep in step.
`workspace.initializeDefault` therefore takes no request at all; `WorkspaceInitializeDefaultRequest` and its name validation are deleted, and the registry's resolver returns a path instead of a path and title. No language reaches the Host, so nothing there can disagree with the Client about naming.
Browser consumers label one Workspace through `workspaceDisplayTitle(title, localizedDefault)`: a title still equal to the automatic one reads as the localized default name, and every other title reads verbatim. The name lives in the `common` locale namespace (`workspace.defaultName`) because two packages show it — the Workspace browser resolves it once at the store read so rows, hover card, search meta, and the rename and delete dialogs all inherit it, and the Conversation workspace chip resolves it for the hero.
A rename is what stops a row following the language, and needs no stored flag: the dialog is seeded with the label on screen while the *stored* title decides whether confirming saves. The two differ for a Workspace still carrying its automatic title, so confirming the untouched prefill is a real rename that pins that name. Self-exclusion in the duplicate check moved to Workspace identity for the same reason — the seeded label equals the row's own displayed title without being a conflict with itself.
## Alternatives considered
- Marking the never-renamed state on `WorkspaceView` (a boolean the Host derives from `defaultWorkspaceId` plus the automatic title) removes the false positive below, but widens a shared transport type to carry presentation state the Host has no other use for. Comparing the stored title is the same test with no wire surface.
- Localizing the title inside the Client Workspace store would cover every consumer at one point, but a store that returns product copy owes the locale revision a re-projection and puts copy outside the `t` seat the i18n gate checks.
- Keeping the localized directory name and adding a separate stored title preserves the old path spellings, but leaves every installation's path decided by its first language — the defect this note exists to remove.
- Renaming an existing localized directory on language change would break recorded session `cwd`s, tool arguments, and `@path` references for a cosmetic gain.
## Consequences
Installations created before this change keep their existing localized directory and title. Nothing renames or relocates them; their titles no longer match the automatic name, so they read verbatim — which is what a user who never renamed a Chinese installation already saw.
A Workspace whose stored title is exactly `default-workspace` is labeled as the default even when it is not the registry's default — a folder of that name adopted from the picker, or a rename to that literal. Nothing but the label depends on the distinction. The Workspace browser's client-side duplicate-title check compares localized titles, so it reports a conflict for a second Workspace named the localized default while the Host, comparing stored titles, would allow it; refusing two rows that read identically is the better answer, and the Host still owns the authoritative uniqueness check.
Two *Session* labels derived from a working directory keep showing the raw path segment, because they name a directory rather than a Workspace: the Session Controller's `displayTitleOf` falls back to the cwd basename for a Session with no durable title, and the sidebar's search-result metadata falls back the same way for a Session with no owning Workspace. Both read `default-workspace` where they previously read the localized name. Routing them through `workspaceDisplayTitle` would spread the automatic-title test into Session naming for a case the Session title generator almost always fills in first.
@@ -0,0 +1,36 @@
# Agent Note: 语言中立的默认工作区命名
Status: implemented
[English](2026-09-23-language-neutral-default-workspace-naming.md) | 中文
## Problem
[首次使用默认工作区](2026-09-20-default-workspace.zh.md)让 Client 按启动语言同时决定目录名和存储标题:中文安装环境创建 `默认工作区`,英文创建 `Default workspace`,其他语言创建 `default-workspace`。读者看到的名称与写入磁盘的名称是同一个字符串,因此一个产品决策承担了两项互不兼容的要求。路径会被 shell 命令、工具参数、`@path` 引用、会话日志和备份寻址,不应取决于安装环境启动时恰好使用哪种语言。标签是给人读的,应当使用读者的语言。事后切换语言两者都无法满足:路径已经固定,由它派生的标签也已固定。
## Decision
目录名与存储标题是同一个固定的、语言中立的字符串;只有屏幕上的标签跟随读者语言。
`DEFAULT_WORKSPACE_DIRECTORY`(`default-workspace`)由 [Workspace 控制器](../../../../packages/api/workspace-controller/README.zh.md#first-use-workspace)拥有,并通过其独立的 `./default-workspace` 子路径导出——它是一个没有任何 import 的纯折叠函数,因此 Client 包会内联它,而不是去请求本包并未发布的模块表条目。Host 将它拼接在 `<Documents>/deepseek-harness` 下,注册表则以规范目录的最后一段作为工作区标题,因此自动标题*就是*同一个名称,不存在需要同步的第二个来源。
因此 `workspace.initializeDefault` 完全不接受请求参数;`WorkspaceInitializeDefaultRequest` 及其名称校验被删除,注册表的解析器返回路径而不是路径加标题。语言不会传到 Host,因此那里不可能与 Client 在命名上产生分歧。
浏览器消费方通过 `workspaceDisplayTitle(title, localizedDefault)` 为单个工作区加标签:标题仍等于自动标题时显示为本地化的默认名称,其他标题一律原样显示。该名称放在 `common` 语言命名空间(`workspace.defaultName`),因为有两个包展示它——Workspace 浏览区在读取 store 时解析一次,行、悬浮卡片、搜索结果元信息以及重命名与删除对话框都继承该结果;Conversation 的工作区 chip 则为 hero 自行解析。
让某一行不再跟随语言的动作就是重命名,无需额外存储标记:对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是*存储*标题。对于仍保留自动标题的工作区两者不同,因此直接确认未改动的初值就是一次真实的重命名,会把该名称固定下来。重名检查中排除自身也因此改为按工作区身份判断——初值标签与该行自己显示的标题相同,但这并不构成与自身的冲突。
## Alternatives considered
- 在 `WorkspaceView` 上标记"从未重命名"状态(由 Host 依据 `defaultWorkspaceId` 与自动标题推导的布尔值)可以消除下述误判,但会让一个共享的传输类型多承载一项 Host 本身别无用途的展示状态。比较存储标题是同一项判断,且不增加 wire 表面。
- 在 Client Workspace store 内部本地化标题可以一处覆盖所有消费方,但返回产品文案的 store 需要在语言修订变化时重新投影,而且会把文案放到 i18n 门禁检查的 `t` 座位之外。
- 保留本地化目录名并另存一个标题可以保住旧路径拼写,但每个安装环境的路径仍由其首个语言决定——正是本笔记要消除的缺陷。
- 在语言切换时重命名已有的本地化目录,会为了外观收益破坏已记录的会话 `cwd`、工具参数和 `@path` 引用。
## Consequences
本次改动之前创建的安装环境保留其既有的本地化目录和标题。不会有任何重命名或迁移;它们的标题不再与自动名称相同,因此原样显示——这与从未重命名过中文安装环境的用户已经看到的结果一致。
存储标题恰好为 `default-workspace` 的工作区会按默认工作区显示,即使它并非注册表的默认工作区——例如从选择器采用的同名文件夹,或被重命名为该字面值。除标签之外没有其他行为依赖该判断。Workspace 浏览区在 Client 侧的重名检查比较的是本地化标题,因此当第二个工作区被命名为本地化默认名称时它会报冲突,而按存储标题比较的 Host 会允许;拒绝两行显示完全相同的名称是更好的答案,权威的唯一性检查仍由 Host 拥有。
有两处由工作目录派生的 *Session* 标签仍显示原始路径片段,因为它们命名的是目录而非工作区:Session Controller 的 `displayTitleOf` 在 Session 没有持久标题时回退到 cwd 最后一段,侧栏搜索结果的元信息在 Session 没有所属工作区时同样回退。两处此前显示本地化名称,现在显示 `default-workspace`。把它们接入 `workspaceDisplayTitle` 会把自动标题判断扩散进 Session 命名,而这种情形几乎总会先被 Session 标题生成填上。
+8 -3
View File
@@ -34,6 +34,10 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => {
await page.goto(scaffold.authenticatedUrl)
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
await input.waitFor()
// Language-neutral on disk, localized in the hero chip. The chip's
// aria-label is its fixed call to action, so the label is its text.
await expect.poll(() => page.getByRole('button', { name: 'Choose workspace', exact: true }).textContent())
.toBe('Default workspace')
expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1)
const initialSession = scaffold.ctx.sessions.list()[0]!
expect(scaffold.ctx.sessions.list()).toHaveLength(1)
@@ -47,8 +51,8 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => {
await input.press('Enter')
const sessionId = await settled
const workspace = scaffold.ctx.workspaceRegistry.list()[0]!
expect(workspace.title).toBe('Default workspace')
expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace'))
expect(workspace.title).toBe('default-workspace')
expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'default-workspace'))
expect((await stat(workspace.path)).isDirectory()).toBe(true)
expect(workspace.sessionIds).toContain(sessionId)
expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path)
@@ -71,7 +75,8 @@ describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => {
onTestFinished(() => scaffold.close())
const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness')
await mkdir(parent, { recursive: true })
await writeFile(join(parent, '默认工作区'), 'occupied')
// A Chinese reader gets the same fixed directory name, so the same occupant conflicts.
await writeFile(join(parent, 'default-workspace'), 'occupied')
const chosen = join(scaffold.workspaceCwd, 'chosen')
await mkdir(chosen)
const browser = await chromium.launch()
+1 -1
View File
@@ -812,7 +812,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}])
}
if (options.firstUse !== true && ctx.workspaceRegistry.list().length === 0) {
const initial = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: workspaceCwd, title: 'Workspace' }))
const initial = await ctx.workspaceRegistry.initializeDefault(async () => workspaceCwd)
if (initial !== undefined) await ctx.workspaceRegistry.delete(initial.id)
}
const boundPort = ctx.get('webServer')?.port
+1 -1
View File
@@ -303,7 +303,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-workspace-controller`
- `inject`: `typert` · `workspaceRegistry`
- `source`: [`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts)
- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts)
```ts config-catalog
/** First-use directory policy for the Host account. */
+1 -1
View File
@@ -305,7 +305,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-workspace-controller`
- `inject`: `typert` · `workspaceRegistry`
- `source`: [`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts)
- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts)
```ts config-catalog
/** First-use directory policy for the Host account. */
+2 -2
View File
@@ -15,8 +15,8 @@
en: 7ba5b4e06c162bd6
zh: f1dd7eacacd07e0b
/workspaces/default-workspace-initialization:
en: 9446b81370c5492f
zh: 92852f2999d766f2
en: c4307480e504ba81
zh: dc8a98e1b447b900
/workspaces/session-pinning:
en: 3f5ab940adbfab1e
zh: f759cd34e3b06ca1
+12 -8
View File
@@ -123,7 +123,7 @@ Sessions get their cwd at create time from whoever creates them, not from this r
## Default Workspace initialization
The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspaceInitializeDefaultRequest`: a Client-resolved `directoryName` and initial `title`. The Host resolves the Documents location and asks the registry to initialize once. Locale selection belongs to the Client; the registry accepts a directory resolver and commits the registration with its durable identity. [First-use behavior and configuration](../../packages/api/workspace-controller/README.md#first-use-workspace) describe reuse and failure handling.
The controller's `initializeDefault` takes no request: it owns the fixed `default-workspace` directory name, resolves the Documents location, and asks the registry to initialize once. The registry accepts a directory resolver, derives the initial title from the requested directory's final segment rather than the canonical one, and commits the registration with its durable identity. No language reaches the Host — browser consumers label a Workspace still carrying that automatic title through the controller's `workspaceDisplayTitle`, so only the on-screen name follows the reader's language. [First-use behavior and configuration](../../packages/api/workspace-controller/README.md#first-use-workspace) describe reuse and failure handling.
## Session pinning
@@ -339,12 +339,14 @@ Host service backing the generated `ctx.remote.workspace` namespace.
@Remote('create') create(request: WorkspaceCreateRequest): Promise<WorkspaceCreateValue>
/**
* Initialize or reuse the default Workspace during first-use startup.
* @param request - initial directory name and title; never rename an existing default.
* Initialize or reuse the default Workspace during first-use startup. The
* directory name is fixed, so the Host never renames or relocates an
* existing default; its initial title is that same name, which browser
* consumers label in the reader's language.
* @param signal - caller lifetime; cancels native directory lookup.
* @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.
*/
@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined>
@Remote('initializeDefault') async initializeDefault(signal: AbortSignal): Promise<WorkspaceValue | undefined>
/**
* Rename one Workspace to a unique non-blank title.
@@ -495,13 +497,15 @@ async create(path: string, title?: string): Promise<Workspace>
* Initialize the default Workspace only while both the registry and Session
* history are empty. Repeated requests reuse its durable identity; deleting
* that registration permanently disables automatic creation.
* @param resolveDirectory - resolve the absolute directory and initial title;
* called only for eligible creation, inside the registry mutation queue.
* Missing directories are created recursively before registration.
* @param resolveDirectory - resolve the absolute directory; called only for
* eligible creation, inside the registry mutation queue. Missing directories
* are created recursively before registration, and the initial title is the
* requested directory's own final segment — not the canonical one, so a
* symlink at that path does not retitle the Workspace after its target.
* After resolution, caller cancellation does not roll back creation or registration.
* @returns the initialized Workspace, or undefined when automatic creation is ineligible.
*/
initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise<Workspace | undefined>
initializeDefault(resolveDirectory: () => Promise<string>): Promise<Workspace | undefined>
/**
* Look up a workspace by id.
+12 -8
View File
@@ -123,7 +123,7 @@ interface Workspace {
## 默认工作区初始化
控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspaceInitializeDefaultRequest`,包含 Client 解析出的 `directoryName` 和初始 `title`。Host 解析 Documents 位置,并请求注册表执行一次初始化。语言选择由 Client 负责;注册表接收目录解析器,并将登记与持久化身份一起提交。[首次使用行为与配置](../../packages/api/workspace-controller/README.zh.md#first-use-workspace)说明复用和失败处理。
控制器的 `initializeDefault` 不接受请求参数:它拥有固定目录名 `default-workspace`,解析 Documents 位置,并请求注册表执行一次初始化。注册表接收目录解析器,以所请求目录(而非规范路径)的最后一段作为初始标题,并将登记与持久化身份一起提交。语言不会传到 Host——浏览器消费方通过控制器的 `workspaceDisplayTitle` 为仍保留该自动标题的工作区加标签,因此只有屏幕上的名称跟随读者语言。[首次使用行为与配置](../../packages/api/workspace-controller/README.zh.md#first-use-workspace)说明复用和失败处理。
## 会话置顶
@@ -339,12 +339,14 @@ Host service backing the generated `ctx.remote.workspace` namespace.
@Remote('create') create(request: WorkspaceCreateRequest): Promise<WorkspaceCreateValue>
/**
* Initialize or reuse the default Workspace during first-use startup.
* @param request - initial directory name and title; never rename an existing default.
* Initialize or reuse the default Workspace during first-use startup. The
* directory name is fixed, so the Host never renames or relocates an
* existing default; its initial title is that same name, which browser
* consumers label in the reader's language.
* @param signal - caller lifetime; cancels native directory lookup.
* @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.
*/
@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined>
@Remote('initializeDefault') async initializeDefault(signal: AbortSignal): Promise<WorkspaceValue | undefined>
/**
* Rename one Workspace to a unique non-blank title.
@@ -495,13 +497,15 @@ async create(path: string, title?: string): Promise<Workspace>
* Initialize the default Workspace only while both the registry and Session
* history are empty. Repeated requests reuse its durable identity; deleting
* that registration permanently disables automatic creation.
* @param resolveDirectory - resolve the absolute directory and initial title;
* called only for eligible creation, inside the registry mutation queue.
* Missing directories are created recursively before registration.
* @param resolveDirectory - resolve the absolute directory; called only for
* eligible creation, inside the registry mutation queue. Missing directories
* are created recursively before registration, and the initial title is the
* requested directory's own final segment — not the canonical one, so a
* symlink at that path does not retitle the Workspace after its target.
* After resolution, caller cancellation does not roll back creation or registration.
* @returns the initialized Workspace, or undefined when automatic creation is ineligible.
*/
initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise<Workspace | undefined>
initializeDefault(resolveDirectory: () => Promise<string>): Promise<Workspace | undefined>
/**
* Look up a workspace by id.
@@ -18,8 +18,8 @@
en: aa0d93826ab685fe
zh: 144e48d49020f5a4
/workspace-controller/use-this-package/first-use-workspace:
en: 5d7a5ff08c15aec5
zh: 7668ed81af0d70d8
en: 61032e060e7213f0
zh: 437eb982a8738539
/workspace-controller/model-experience:
en: 4f399d6a27daf683
zh: 13811489fe636127
+4 -2
View File
@@ -29,9 +29,11 @@ The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream
<a id="first-use-workspace"></a>
### First-use Workspace
`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Workspace Client](../../client/ui-workspace/README.md) resolves these names from its startup language. The Host places the directory under its account's `<Documents>/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker.
`workspace.initializeDefault()` returns the durable default Workspace; the Client service exposes it as `workspaces.initializeDefault(signal?)`. It takes no request: the Host owns the fixed `default-workspace` directory name, and the registry derives the initial title from that same segment, so one installation keeps one on-disk path and one stored title in every language. The Host places the directory under its account's `<Documents>/deepseek-harness`, including on remote Web hosts. OS filename restrictions apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker.
The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Ineligible first use returns `undefined`, so startup can leave directory selection to the user. Invalid names reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message.
The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup and is never renamed or relocated. Ineligible first use returns `undefined`, so startup can leave directory selection to the user. Lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message.
`DEFAULT_WORKSPACE_DIRECTORY` and `workspaceDisplayTitle(title, localizedDefault)` are published from `./default-workspace` for browser consumers: a Workspace still carrying the automatic title reads as the reader's localized default name, and every other title reads verbatim. A Workspace the user renamed to exactly `default-workspace` — or a folder of that name adopted from the picker — is labeled as the default; nothing else depends on the distinction.
| Configuration | Default | Purpose |
| --- | --- | --- |
@@ -29,9 +29,11 @@ Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。
<a id="first-use-workspace"></a>
### 首次使用工作区
`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Workspace Client](../../client/ui-workspace/README.zh.md)按启动时的语言解析这些名称。Host 将目录放在其账户的 `<Documents>/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。
`workspace.initializeDefault()` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(signal?)` 提供该操作。它不接受请求参数:固定目录名 `default-workspace` 由 Host 拥有,注册表也以同一路径片段作为初始标题,因此任何语言下同一安装环境都只有一个磁盘路径和一个存储标题。Host 将目录放在其账户的 `<Documents>/deepseek-harness` 下,远程 Web Host 也遵循此规则。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。
[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。不满足首次使用条件时返回 `undefined`,启动流程可将目录选择留给用户。名称无效时以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。
[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents,也不会被重命名或迁移。不满足首次使用条件时返回 `undefined`,启动流程可将目录选择留给用户。查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。
`./default-workspace` 为浏览器消费方导出 `DEFAULT_WORKSPACE_DIRECTORY` 与 `workspaceDisplayTitle(title, localizedDefault)`:仍保留自动标题的工作区按读者语言显示默认名称,其他标题一律原样显示。被用户重命名为 `default-workspace` 的工作区,或从选择器采用的同名文件夹,也会按默认工作区显示;除显示之外没有其他行为依赖该判断。
| 配置 | 默认值 | 用途 |
| --- | --- | --- |
@@ -26,6 +26,10 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./default-workspace": {
"types": "./lib/types/default-workspace.d.ts",
"default": "./lib/types/default-workspace.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
@@ -19,7 +19,7 @@ export { WorkspaceArchiveError, WorkspaceController, WorkspaceCreateError } from
export type { IWorkspaces, WorkspaceSource } from './service.ts'
export type {
SessionActivity, SessionActivityItem, SessionActivityKind, SessionActivityKindMap, WorkspaceId,
WorkspaceInitializeDefaultRequest, WorkspaceView,
WorkspaceView,
} from '../types.ts'
type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
@@ -12,7 +12,6 @@ import type {
WorkspaceCreateValue,
WorkspaceDeleteValue,
WorkspaceInsertSessionBeforeRequest,
WorkspaceInitializeDefaultRequest,
WorkspaceOrderValue,
WorkspacePinSessionRequest,
WorkspacePinValue,
@@ -104,14 +103,11 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
/**
* Initialize the default Workspace and merge its authoritative row.
* @param request - initial directory name and title.
* @param signal - caller lifetime.
* @returns generated Remote result.
*/
async initializeDefault(
request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal,
): Promise<RemoteResult<WorkspaceValue | undefined>> {
const result = await this.remote.initializeDefault(request, signal)
async initializeDefault(signal?: AbortSignal): Promise<RemoteResult<WorkspaceValue | undefined>> {
const result = await this.remote.initializeDefault(signal)
if (result.ok && result.value !== undefined) this.upsert(result.value.workspace)
return result
}
@@ -4,7 +4,7 @@ import { Service, type Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts'
import type { WorkspaceView } from '../types.ts'
import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts'
/** Structured create failure for callers that distinguish Host business errors. */
@@ -55,11 +55,10 @@ export interface IWorkspaces {
create(input: { path: string }): Promise<WorkspaceView>
/**
* Initialize or reuse the default Workspace.
* @param request - initial directory name and title.
* @param signal - caller lifetime.
* @returns the prepared Workspace, or undefined when first-use initialization is ineligible; rejects on preparation failure.
*/
initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView | undefined>
initializeDefault(signal?: AbortSignal): Promise<WorkspaceView | undefined>
/**
* Rename a Workspace.
* @param workspaceId - target Workspace.
@@ -134,8 +133,8 @@ export class WorkspaceController extends Service implements IWorkspaces {
return result.value.workspace
}
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView | undefined> {
const result = await this.model.initializeDefault(request, signal)
async initializeDefault(signal?: AbortSignal): Promise<WorkspaceView | undefined> {
const result = await this.model.initializeDefault(signal)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value?.workspace
}
@@ -3,6 +3,7 @@
import { homedir } from 'node:os'
import { posix, win32 } from 'node:path'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
import { DEFAULT_WORKSPACE_DIRECTORY } from './default-workspace.ts'
/** Platform observations replaceable in directory-resolution tests. */
interface DocumentsDirectoryInternals {
@@ -28,14 +29,12 @@ export function validateDocumentsDirectory(directory: string, platform: NodeJS.P
/**
* Resolve the first-use directory on the Host without creating files.
* @param directoryName - validated single directory name supplied by the Client.
* @param documentsDirectory - explicit deployment override for the system Documents directory.
* @param signal - caller lifetime and lookup deadline.
* @param internals - platform facts and native command runner.
* @returns the absolute candidate path.
*/
export async function defaultWorkspaceDirectory(
directoryName: string,
documentsDirectory: string | undefined,
signal: AbortSignal,
internals: DocumentsDirectoryInternals = {},
@@ -75,5 +74,5 @@ export async function defaultWorkspaceDirectory(
}
directory = validateDocumentsDirectory(directory, platform)
signal.throwIfAborted()
return paths.join(directory, 'deepseek-harness', directoryName)
return paths.join(directory, 'deepseek-harness', DEFAULT_WORKSPACE_DIRECTORY)
}
@@ -0,0 +1,30 @@
/**
* Fixed first-use Workspace naming, shared by the Host that creates the
* directory and by browser consumers that label the resulting row. A pure fold
* with no imports, so client bundles inline it instead of requesting a
* module-table row this package does not publish.
* @module @deepseek-ai/dsh-api-workspace-controller/default-workspace
*/
/**
* Leaf directory name of the first-use Workspace under
* `<Documents>/deepseek-harness`. Language-neutral, so one installation keeps
* one on-disk path across language switches. The registry derives the initial
* title from this same segment, which is the title
* {@link workspaceDisplayTitle} recognizes as automatic.
*/
export const DEFAULT_WORKSPACE_DIRECTORY = 'default-workspace'
/**
* The text one Workspace is labeled with. A Workspace still carrying the
* automatic first-use title reads as the caller's localized default name; every
* other title reads verbatim in every language. A title the user typed as
* exactly {@link DEFAULT_WORKSPACE_DIRECTORY} is labeled as the default too;
* nothing else depends on the distinction.
* @param title - stored Workspace title.
* @param localizedDefault - the default Workspace name in the active language.
* @returns the title to display.
*/
export function workspaceDisplayTitle(title: string, localizedDefault: string): string {
return title === DEFAULT_WORKSPACE_DIRECTORY ? localizedDefault : title
}
+8 -13
View File
@@ -2,7 +2,7 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { WorkspaceCommands } from './commands.ts'
import { DirectoryPickerController } from './directory-picker.ts'
import { WorkspaceFeed, workspaceView } from './feed.ts'
@@ -16,7 +16,6 @@ import type {
WorkspaceDeleteValue,
WorkspaceFollowFrame,
WorkspaceInsertBeforeRequest,
WorkspaceInitializeDefaultRequest,
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspacePinSessionRequest,
@@ -89,24 +88,20 @@ export class WorkspaceController extends TypertRemoteService {
}
/**
* Initialize or reuse the default Workspace during first-use startup.
* @param request - initial directory name and title; never rename an existing default.
* Initialize or reuse the default Workspace during first-use startup. The
* directory name is fixed, so the Host never renames or relocates an
* existing default; its initial title is that same name, which browser
* consumers label in the reader's language.
* @param signal - caller lifetime; cancels native directory lookup.
* @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.
*/
@Remote('initializeDefault')
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined> {
const { directoryName, title } = request
if (directoryName.trim() === '' || directoryName !== directoryName.trim()
|| directoryName.endsWith('.') || /[/\\:\0]/.test(directoryName) || title.trim() === '') {
throw new RemoteError('gateway/bad-request', 'default Workspace requires a directory name and non-blank title', {})
}
async initializeDefault(signal: AbortSignal): Promise<WorkspaceValue | undefined> {
const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => {
const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs)
const path = await defaultWorkspaceDirectory(
directoryName, this.config.documentsDirectory, AbortSignal.any([signal, timeout]),
return await defaultWorkspaceDirectory(
this.config.documentsDirectory, AbortSignal.any([signal, timeout]),
)
return { path, title }
})
return workspace === undefined ? undefined : { workspace: workspaceView(workspace) }
}
@@ -66,14 +66,6 @@ export interface WorkspaceCreateRequest {
readonly path: string
}
/** Names chosen by the first-use Client before default Workspace initialization. */
export interface WorkspaceInitializeDefaultRequest {
/** Host rejects blank names, separators, colon, NUL, surrounding whitespace, and trailing dots; OS filename restrictions also apply. */
readonly directoryName: string
/** Initial display title, retained across language changes. */
readonly title: string
}
/** Created or previously registered Workspace. */
export interface WorkspaceCreateValue {
readonly workspace: WorkspaceView
@@ -4,36 +4,36 @@ import { defaultWorkspaceDirectory, validateDocumentsDirectory } from '../src/de
describe('system Documents directory', () => {
it.each([
['darwin', '/Users/a/文档/\n', '/Users/a/文档/deepseek-harness/默认工作区', 'osascript'],
['win32', 'D:\\Redirected Documents\r\n', 'D:\\Redirected Documents\\deepseek-harness\\默认工作区', 'powershell.exe'],
['linux', '/home/a/My Documents\n', '/home/a/My Documents/deepseek-harness/默认工作区', 'xdg-user-dir'],
['darwin', '/Users/a/文档/\n', '/Users/a/文档/deepseek-harness/default-workspace', 'osascript'],
['win32', 'D:\\Redirected Documents\r\n', 'D:\\Redirected Documents\\deepseek-harness\\default-workspace', 'powershell.exe'],
['linux', '/home/a/My Documents\n', '/home/a/My Documents/deepseek-harness/default-workspace', 'xdg-user-dir'],
] as const)('uses the %s account directory and preserves spaces and Unicode', async (platform, stdout, path, command) => {
const run = vi.fn<NativeCommandRunner>(async () => ({ stdout, stderr: '' }))
const signal = new AbortController().signal
await expect(defaultWorkspaceDirectory('默认工作区', undefined, signal, { platform, run })).resolves.toBe(path)
await expect(defaultWorkspaceDirectory(undefined, signal, { platform, run })).resolves.toBe(path)
expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal)
})
it.each(['Default workspace', '默认工作区', 'default-workspace'])('uses the configured directory with name %s', async (folder) => {
it('uses the configured directory without a system lookup', async () => {
const run = vi.fn<NativeCommandRunner>()
await expect(defaultWorkspaceDirectory(folder, '/documents', new AbortController().signal, { platform: 'linux', run }))
.resolves.toBe(`/documents/deepseek-harness/${folder}`)
await expect(defaultWorkspaceDirectory('/documents', new AbortController().signal, { platform: 'linux', run }))
.resolves.toBe('/documents/deepseek-harness/default-workspace')
expect(run).not.toHaveBeenCalled()
})
it.each(['', '\r\n', '/home/a\n'])('rejects an unavailable XDG directory %j', async (stdout) => {
const run: NativeCommandRunner = async () => ({ stdout, stderr: '' })
await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'linux', home: '/home/a', run }))
await expect(defaultWorkspaceDirectory(undefined, new AbortController().signal, { platform: 'linux', home: '/home/a', run }))
.rejects.toThrow('unavailable')
})
it('propagates lookup failure and cancellation', async () => {
const run: NativeCommandRunner = async () => { throw new Error('lookup denied') }
await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'darwin', run }))
await expect(defaultWorkspaceDirectory(undefined, new AbortController().signal, { platform: 'darwin', run }))
.rejects.toThrow('lookup denied')
await expect(defaultWorkspaceDirectory('Default workspace', '/documents', AbortSignal.abort(), { platform: 'linux' }))
await expect(defaultWorkspaceDirectory('/documents', AbortSignal.abort(), { platform: 'linux' }))
.rejects.toThrow()
await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'freebsd' }))
await expect(defaultWorkspaceDirectory(undefined, new AbortController().signal, { platform: 'freebsd' }))
.rejects.toThrow('unavailable')
})
@@ -176,18 +176,18 @@ describe('ClientWorkspaceModel', () => {
const model = modelFor(remote)
baseline(model)
const signal = new AbortController().signal
await expect(model.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).resolves.toMatchObject({ ok: true })
expect(remote.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, signal)
await expect(model.initializeDefault(signal)).resolves.toMatchObject({ ok: true })
expect(remote.initializeDefault).toHaveBeenCalledWith(signal)
expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['default'])
const before = model.getSnapshot()
remote.initializeDefault.mockResolvedValueOnce(remoteOk(undefined))
await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }))
await expect(model.initializeDefault())
.resolves.toEqual({ ok: true, value: undefined })
expect(model.getSnapshot()).toBe(before)
remote.initializeDefault.mockResolvedValueOnce(workspaceError(
new RemoteError('gateway/bad-request', 'choose a folder', {}),
))
await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).resolves.toMatchObject({ ok: false })
await expect(model.initializeDefault()).resolves.toMatchObject({ ok: false })
expect(model.getSnapshot()).toBe(before)
})
@@ -318,8 +318,7 @@ describe('WorkspaceController', () => {
const controller = new WorkspaceController(client.ctx, model)
const before = model.getSnapshot()
mock.remote.workspace.initializeDefault.mockResolvedValueOnce({ ok: true, value: undefined })
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }))
.resolves.toBeUndefined()
await expect(controller.initializeDefault()).resolves.toBeUndefined()
expect(model.getSnapshot()).toBe(before)
})
@@ -331,7 +330,7 @@ describe('WorkspaceController', () => {
expect(controller.list).toBe(model)
expect(client.ctx.workspaces.list).toBe(model)
await expect(controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, new AbortController().signal)).resolves.toMatchObject({ workspaceId: 'default' })
await expect(controller.initializeDefault(new AbortController().signal)).resolves.toMatchObject({ workspaceId: 'default' })
await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' })
await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' })
await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined()
@@ -344,7 +343,6 @@ describe('WorkspaceController', () => {
await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined()
await expect(controller.delete(wid('one'))).resolves.toBeUndefined()
// Each command crosses the wire as one positional request object.
expect(mock.log.requests('workspace/initializeDefault')).toEqual([{ directoryName: '默认工作区', title: '默认工作区' }])
expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }])
expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }])
expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }])
@@ -364,7 +362,7 @@ describe('WorkspaceController', () => {
const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') })
mock.remote.workspace.initializeDefault.mockResolvedValueOnce(err(new RemoteError('gateway/internal', 'directory denied', {})))
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).rejects.toBeInstanceOf(WorkspaceCreateError)
await expect(controller.initializeDefault()).rejects.toBeInstanceOf(WorkspaceCreateError)
mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' })))
const create = controller.create({ path: '/missing' })
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -10,6 +10,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import WorkspaceController from '../src/index.ts'
import { DEFAULT_WORKSPACE_DIRECTORY } from '../src/default-workspace.ts'
import { WorkspaceFeed } from '../src/feed.ts'
import type { WorkspaceFollowFrame } from '../src/types.ts'
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
@@ -450,53 +451,30 @@ describe('WorkspaceController follow', () => {
describe('first-use Remote', () => {
it('reuses an initialized Workspace without looking up system Documents', async () => {
const { controller, ctx, root } = await harness({ systemDocuments: true })
const workspace = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: root, title: 'Existing default' }))
const workspace = await ctx.workspaceRegistry.initializeDefault(async () => root)
const signal = AbortSignal.abort()
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal))
.resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: 'Existing default' } })
await expect(controller.initializeDefault(signal))
.resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: basename(root) } })
})
it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])(
'rejects invalid directory name %j before invoking the registry', async (directoryName) => {
const { controller, ctx } = await harness()
const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined)
await expect(controller.initializeDefault({ directoryName, title: 'Default workspace' }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/bad-request' })
expect(initialize).not.toHaveBeenCalled()
},
)
it('rejects a blank initial title before invoking the registry', async () => {
const { controller, ctx } = await harness()
const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined)
await expect(controller.initializeDefault({ directoryName: 'default-workspace', title: ' ' }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/bad-request' })
expect(initialize).not.toHaveBeenCalled()
})
it('uses the requested display title independently of the directory name', async () => {
const { controller, root } = await harness()
const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal)
expect(result?.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' })
})
it('returns a durable Workspace without allocating a Session', async () => {
it('returns a durable Workspace named after its fixed directory without allocating a Session', async () => {
const { controller, ctx, root } = await harness()
const signal = new AbortController().signal
const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal)
expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace'))
const result = await controller.initializeDefault(signal)
expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', DEFAULT_WORKSPACE_DIRECTORY))
expect(result!.workspace.title).toBe(DEFAULT_WORKSPACE_DIRECTORY)
expect(existsSync(result!.workspace.path)).toBe(true)
expect(ctx.sessions.list()).toEqual([])
expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result)
expect(await controller.initializeDefault(signal)).toEqual(result)
})
it('skips ineligible first use and propagates preparation failures', async () => {
const { controller, ctx, root } = await harness()
await ctx.workspaceRegistry.create(root)
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal))
await expect(controller.initializeDefault(new AbortController().signal))
.resolves.toBeUndefined()
vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied'))
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal))
await expect(controller.initializeDefault(new AbortController().signal))
.rejects.toThrow('permission denied')
})
})
@@ -9,6 +9,7 @@
"src/client/index.ts",
"src/client/model.ts",
"src/client/service.ts",
"src/default-workspace.ts",
"src/types.ts"
],
"references": [
@@ -9,6 +9,7 @@
"src/index.ts",
"src/types.ts",
"src/commands.ts",
"src/default-workspace.ts",
"src/directory-picker.ts",
"src/feed.ts",
"src/default-directory.ts"
+1
View File
@@ -34,6 +34,7 @@ export const en = {
'expand': 'Expand',
'back': 'Back',
'brand.localBuild': 'DSH Local Build',
'workspace.defaultName': 'Default workspace',
'unknown': 'Unknown',
'none': 'None',
'truncated': 'Truncated',
+1
View File
@@ -32,6 +32,7 @@ export const zh = {
'expand': '展开',
'back': '返回',
'brand.localBuild': 'DSH 本地构建',
'workspace.defaultName': '默认工作区',
'unknown': '未知',
'none': '无',
'truncated': '已截断',
+1 -1
View File
@@ -61,7 +61,7 @@ function styleInjectionModule(
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { workspaceDisplayTitle } from '@deepseek-ai/dsh-api-workspace-controller/default-workspace'
import type { ConversationContentProps, ConversationViewsProps, InputZone } from '../contract/slots.ts'
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import css from './ConversationRoot.module.css'
@@ -93,13 +94,18 @@ export function ConversationContent(props: ConversationContentProps) {
// flash on refresh (empty cwd → placeholder);
// 5. list ready but no owning workspace (deleted from the sidebar) →
// placeholder, never the deleted folder's name via cwd.
const chipTitle = pendingWorkspace?.title
// A title still automatic reads in the reader's language, matching the
// sidebar row the same Workspace has there.
const storedChipTitle = pendingWorkspace?.title
?? (sessionId === undefined
? undefined
: sessionWorkspace?.title
?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
? undefined
: workspaceLabel(cwd)))
const chipTitle = storedChipTitle === undefined
? undefined
: workspaceDisplayTitle(storedChipTitle, t('workspace.defaultName'))
const heroWorkspaceRow = (
<div className={css.heroWorkspaceRow}>
@@ -33,8 +33,8 @@
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
en: 46912cf613b2ae41
zh: 2b17e226e5d18831
en: dd0a7799c76c0f94
zh: 48d1cdacc9168aa2
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 21590da1f7488361
zh: 95916475cced409e
+1 -1
View File
@@ -73,7 +73,7 @@ New Session tries to acquire the first eligible blank in catalog order; startup
Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Default Workspace creation failure shows a transient notice directing the user to Choose workspace, and is not retried until the next startup. Session creation failures use the ordinary restoration error handling. The registered Workspace remains available if Session creation or later submission fails.
The Client chooses the initial directory name and title from its language at startup: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A successfully initialized Workspace keeps its directory and title across language changes.
The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title.
<a id="understand-the-implementation"></a>
## Understand the implementation
+1 -1
View File
@@ -73,7 +73,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。
Client 在启动时按其语言选择初始目录名和标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化过程中保留请求中的名称。成功初始化的工作区在切换语言后保留原目录和标题。
首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -16,6 +16,7 @@ import {
import type {
WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import { workspaceDisplayTitle } from '@deepseek-ai/dsh-api-workspace-controller/default-workspace'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
@@ -111,7 +112,7 @@ export function WorkspacePickFlow({
const items: MenuEntry[] = pinAdd
? workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
label: workspaceDisplayTitle(workspace.title, t('workspace.defaultName')),
icon: <IconFolderCloseRegular size={16} />,
disabled: flowBusy,
}))
@@ -7,7 +7,6 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'defaultWorkspace.failed': '无法创建默认工作区,请通过“选择工作区”选择文件夹',
'defaultWorkspace.title': '默认工作区',
'group.ungrouped': '未分组',
'session.new': '新会话',
'shortcut.hint': '{label} {keys}',
@@ -127,7 +126,6 @@ export type WorkspaceKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'defaultWorkspace.failed': 'Unable to create default workspace. Use Choose workspace to select a folder.',
'defaultWorkspace.title': 'Default workspace',
'group.ungrouped': 'Ungrouped',
'session.new': 'New Session',
'shortcut.hint': '{label} {keys}',
@@ -16,9 +16,7 @@ import type {
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { RowToast } from './contract/slots.ts'
import { en, zh } from './locales.ts'
import { pinOrderAccounts, pinOrderSource } from './pin-order.ts'
import type { WorkspaceViewStoreActions } from './stores.ts'
@@ -348,13 +346,8 @@ class UiWorkspaceService extends Service implements UiWorkspace {
}
private async initializeDefaultWorkspace(signal: AbortSignal): Promise<WorkspaceView | undefined> {
const language = this.ctx.locale.getSnapshot().active.toLowerCase().split('-')[0]
const title = (language === 'zh' ? zh : en)['defaultWorkspace.title']
try {
return await this.workspaces.initializeDefault({
directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace',
title,
}, signal)
return await this.workspaces.initializeDefault(signal)
} catch (_error: unknown) {
if (!signal.aborted) this.notify({ kind: 'defaultWorkspaceFailed' })
return undefined
@@ -27,6 +27,7 @@ import type {
SessionListState, SessionSearchResultItem,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import { workspaceDisplayTitle } from '@deepseek-ai/dsh-api-workspace-controller/default-workspace'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
@@ -873,7 +874,17 @@ export function WorkspaceBrowser({
? t('shortcut.hint', { label, keys: keys.join(' ') }) : label
// Ordering remains live while the rail or search replaces the list body.
const list = useSessions(state => state)
const workspaces = useWorkspaces(state => state.items)
const storedWorkspaces = useWorkspaces(state => state.items)
// The resolved name, not `t`, is the memo dependency: the bound seat keeps
// its identity across a language switch.
const defaultWorkspaceName = t('workspace.defaultName')
const workspaces = useMemo(
() => storedWorkspaces.map(workspace => ({
...workspace,
title: workspaceDisplayTitle(workspace.title, defaultWorkspaceName),
})),
[storedWorkspaces, defaultWorkspaceName],
)
const workspacePhase = useWorkspaces(state => state.phase)
const workspaceStreamState = useWorkspaces(state => state.state)
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
@@ -1119,15 +1130,21 @@ export function WorkspaceBrowser({
}, [normalizedQuery, searchSessions])
// Rename dialog (browser-owned so it outlives row unmounts during collapse).
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
// The stored title decides whether confirming is a real rename; the draft is
// seeded with the label on screen. They differ for a Workspace still
// carrying its automatic title, so confirming the prefill pins that name.
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; storedTitle: string } | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const [renaming, setRenaming] = useState(false)
const [renameError, setRenameError] = useState<string | null>(null)
const renameTrimmed = renameDraft.trim()
const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle
&& workspaces.some(w => w.title === renameTrimmed)
// Self is excluded by identity, not by title: the draft is seeded with the
// localized label, which for an automatically titled Workspace equals its
// own displayed title without being a conflict with itself.
const renameDuplicate = renameTarget !== null && renameTrimmed !== ''
&& workspaces.some(w => w.workspaceId !== renameTarget.workspaceId && w.title === renameTrimmed)
const renameBlocked = renaming || renameTrimmed === ''
|| renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate
|| renameTarget === null || renameTrimmed === renameTarget.storedTitle || renameDuplicate
const closeRename = () => {
if (renaming) return
setRenameTarget(null)
@@ -1388,9 +1405,12 @@ export function WorkspaceBrowser({
onSessionRevealed={acknowledgeSessionReveal}
home={home}
t={t}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
onRenameRequest={(workspaceId, displayTitle) => {
setRenameTarget({
workspaceId,
storedTitle: storedWorkspaces.find(w => w.workspaceId === workspaceId)?.title ?? displayTitle,
})
setRenameDraft(displayTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
@@ -2231,6 +2231,30 @@ describe('WorkspaceBrowser', () => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
})
it('labels an unrenamed default Workspace in the reader’s language and every other title verbatim', () => {
const renameWorkspace = vi.fn(async () => {})
mount({
useWorkspaces: hook(workspaceState([
workspace('alpha', [], 'default-workspace'),
workspace('beta', [], 'default-workspace backup'),
])),
renameWorkspace,
})
expect(screen.getByRole('button', { name: '工作区“默认工作区”的操作' })).toBeTruthy()
expect(screen.getByRole('button', { name: '工作区“default-workspace backup”的操作' })).toBeTruthy()
// The dialog edits the text on screen, and the stored title — not that
// text — decides whether confirming saves. So confirming the untouched
// prefill pins the localized name and the row stops following the language.
fireEvent.click(screen.getByRole('button', { name: '工作区“默认工作区”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
expect(screen.getByLabelText<HTMLInputElement>('工作区名称').value).toBe('默认工作区')
expect(screen.queryByRole('alert')).toBeNull()
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: '重命名' })
expect(confirm.disabled).toBe(false)
fireEvent.click(confirm)
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), '默认工作区')
})
it('renames a workspace through the row menu dialog', async () => {
let resolveRename!: () => void
const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve }))
@@ -1,6 +1,5 @@
import { setImmediate } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
@@ -267,7 +266,6 @@ class FakeDirectoryPicker {
}
interface BenchOptions {
readonly language?: string
readonly configureWorkspaces?: (workspaces: FakeWorkspaces) => void
readonly workspaces?: WorkspaceSnapshot
readonly sessions?: SessionListState
@@ -277,10 +275,6 @@ interface BenchOptions {
function bench(options: BenchOptions = {}) {
const ctx = new Context()
contexts.push(ctx)
const locale = new LocaleRuntime(ctx)
if (options.language === 'fr') locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
locale.setLocale(options.language ?? 'en')
ctx.provide('locale', locale)
const layout = new LayoutController({
selectPanel: vi.fn(), retainMainPanels: vi.fn(),
setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(),
@@ -308,12 +302,8 @@ function bench(options: BenchOptions = {}) {
}
describe('UiWorkspaceService', () => {
it.each([
['zh', '默认工作区', '默认工作区'],
['en', 'Default workspace', 'Default workspace'],
['fr', 'default-workspace', 'Default workspace'],
])('prepares and selects the default Workspace after both startup baselines (%s)', async (language, directoryName, title) => {
const b = bench({ language, configureWorkspaces: (workspaces) => {
it('prepares and selects the default Workspace after both startup baselines', async () => {
const b = bench({ configureWorkspaces: (workspaces) => {
workspaces.initializeDefault.mockImplementation(async () => {
const item = workspace('default')
workspaces.list.set(workspaceState([item]))
@@ -327,7 +317,7 @@ describe('UiWorkspaceService', () => {
await vi.waitFor(() => {
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-default'), { source: 'mainView' })
})
expect(b.workspaces.initializeDefault).toHaveBeenCalledExactlyOnceWith({ directoryName, title }, expect.any(AbortSignal))
expect(b.workspaces.initializeDefault).toHaveBeenCalledExactlyOnceWith(expect.any(AbortSignal))
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('default') })
expect(b.notify).not.toHaveBeenCalled()
})
@@ -370,7 +360,7 @@ describe('UiWorkspaceService', () => {
if (kind === 'session') b.uiWorkspace.openSession(sid('manual'))
else if (kind === 'panel') b.layout.selectPanel('other-panel' as MainPanelId)
else await b.ctx.fiber.dispose()
expect(b.workspaces.initializeDefault.mock.calls[0]![1]?.aborted).toBe(true)
expect(b.workspaces.initializeDefault.mock.calls[0]![0]?.aborted).toBe(true)
pending.resolve(workspace('default'))
await setImmediate()
expect(b.sessions.create).not.toHaveBeenCalled()
@@ -3505,9 +3505,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the Workspace and whether this call created it.',
},
{
signature: '@Remote(\'initializeDefault\') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise<WorkspaceValue | undefined>',
description: 'Initialize or reuse the default Workspace during first-use startup.',
parameters: [{ name: 'request', description: 'initial directory name and title; never rename an existing default.' }, { name: 'signal', description: 'caller lifetime; cancels native directory lookup.' }],
signature: '@Remote(\'initializeDefault\') async initializeDefault(signal: AbortSignal): Promise<WorkspaceValue | undefined>',
description: 'Initialize or reuse the default Workspace during first-use startup. The directory name is fixed, so the Host never renames or relocates an existing default; its initial title is that same name, which browser consumers label in the reader\'s language.',
parameters: [{ name: 'signal', description: 'caller lifetime; cancels native directory lookup.' }],
returns: 'the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message.',
},
{
@@ -3616,9 +3616,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the existing or newly durable workspace.',
},
{
signature: 'initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise<Workspace | undefined>',
signature: 'initializeDefault(resolveDirectory: () => Promise<string>): Promise<Workspace | undefined>',
description: 'Initialize the default Workspace only while both the registry and Session history are empty. Repeated requests reuse its durable identity; deleting that registration permanently disables automatic creation.',
parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory and initial title; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration. After resolution, caller cancellation does not roll back creation or registration.' }],
parameters: [{ name: 'resolveDirectory', description: 'resolve the absolute directory; called only for eligible creation, inside the registry mutation queue. Missing directories are created recursively before registration, and the initial title is the requested directory\'s own final segment — not the canonical one, so a symlink at that path does not retitle the Workspace after its target. After resolution, caller cancellation does not roll back creation or registration.' }],
returns: 'the initialized Workspace, or undefined when automatic creation is ineligible.',
},
{
@@ -7997,10 +7997,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'WorkspaceFollowIncrement',
declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n} | {\n readonly type: \'pinned\';\n readonly pinnedSessionIds: readonly SessionId[];\n};',
},
{
name: 'WorkspaceInitializeDefaultRequest',
declaration: 'export interface WorkspaceInitializeDefaultRequest {\n readonly directoryName: string;\n readonly title: string;\n}',
},
{
name: 'WorkspaceInsertBeforeRequest',
declaration: 'export interface WorkspaceInsertBeforeRequest {\n readonly workspaceId: WorkspaceId;\n readonly beforeWorkspaceId?: WorkspaceId;\n}',
@@ -1,7 +1,7 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type {
IWorkspaces, WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceSnapshot, WorkspaceView,
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -82,14 +82,13 @@ export class TestWorkspaces implements IWorkspaces {
/**
* Initialize the default Workspace through a test stub; defaults to an ineligible first use.
* @param request - initial directory name and title.
* @param signal - caller lifetime.
* @returns the stubbed Workspace, or undefined when initialization is ineligible.
*/
async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise<WorkspaceView | undefined> {
this.calls.push({ method: 'initializeDefault', args: [request, signal] })
async initializeDefault(signal?: AbortSignal): Promise<WorkspaceView | undefined> {
this.calls.push({ method: 'initializeDefault', args: [signal] })
const stub = this.stubs.get('initializeDefault')
return await (stub?.(request, signal) as Promise<WorkspaceView | undefined> | undefined)
return await (stub?.(signal) as Promise<WorkspaceView | undefined> | undefined)
}
/**
@@ -557,23 +557,22 @@ describe('workspaces', () => {
expect(view.container.textContent).toContain('ws:pending')
await runtime.dispose()
})
it('skips default initialization without a fixture and forwards a configured request and lifetime', async () => {
it('skips default initialization without a fixture and forwards the caller lifetime', async () => {
const runtime = await SlotTestRuntime.create()
try {
const request = { directoryName: 'default-workspace', title: 'Default workspace' }
const signal = new AbortController().signal
await expect(runtime.workspaces.initializeDefault(request, signal)).resolves.toBeUndefined()
await expect(runtime.workspaces.initializeDefault(signal)).resolves.toBeUndefined()
const workspace = {
workspaceId: 'default' as WorkspaceId, title: request.title, path: '/default', sessionIds: [],
workspaceId: 'default' as WorkspaceId, title: 'default-workspace', path: '/default', sessionIds: [],
createdAt: '2026-09-20T00:00:00Z', updatedAt: '2026-09-20T00:00:00Z',
}
const initialize = vi.fn(async () => workspace)
runtime.workspaces.stub('initializeDefault', initialize)
await expect(runtime.workspaces.initializeDefault(request, signal)).resolves.toBe(workspace)
expect(initialize).toHaveBeenCalledWith(request, signal)
await expect(runtime.workspaces.initializeDefault(signal)).resolves.toBe(workspace)
expect(initialize).toHaveBeenCalledWith(signal)
expect(runtime.workspaces.calls).toEqual([
{ method: 'initializeDefault', args: [request, signal] },
{ method: 'initializeDefault', args: [request, signal] },
{ method: 'initializeDefault', args: [signal] },
{ method: 'initializeDefault', args: [signal] },
])
} finally {
await runtime.dispose()
@@ -27,8 +27,8 @@
en: a092fc7b0db9c299
zh: 6d7ed93e34237773
/deepseek-ai-dsh-workspace/use-this-package/first-use-workspace:
en: 82035b4879d77b17
zh: 3a867af2f0e6fc3f
en: 2bfeef54ad410e63
zh: 98b79574586d000a
/deepseek-ai-dsh-workspace/use-this-package/grouping-sessions-under-a-project:
en: 510be21d012de28c
zh: fdcb05447d6ba93c
+1 -1
View File
@@ -64,7 +64,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
`initializeDefault(resolveDirectory)` initializes the default Workspace without creating a Session. Initial creation requires an empty Workspace registry and no live, persisted, or archived Session, including Sessions without a working directory. The registry checks persistent history directly; an empty visible sidebar is insufficient.
The directory resolver runs inside the mutation queue only when creation is eligible. It returns an absolute path and initial title; the registry creates missing parent directories, canonicalizes the path, rechecks Session history, and commits the Workspace with its initialization marker. An existing directory is reused; a file conflict or directory failure rejects initialization. The [Host controller](../../api/workspace-controller/README.md#first-use-workspace) supplies the Documents path policy.
The directory resolver runs inside the mutation queue only when creation is eligible. It returns an absolute path; the registry creates missing parent directories, canonicalizes the path, rechecks Session history, and commits the Workspace with its initialization marker, titled after the requested directory's final segment rather than the canonical one, so a symlink at that path does not retitle the Workspace after its target. An existing directory is reused; a file conflict or directory failure rejects initialization. The [Host controller](../../api/workspace-controller/README.md#first-use-workspace) supplies the Documents path policy.
The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. Once directory resolution succeeds, caller cancellation does not roll back directory creation or registration. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime.
+1 -1
View File
@@ -64,7 +64,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
`initializeDefault(resolveDirectory)` 初始化默认 Workspace,不创建 Session。首次创建要求 Workspace 注册表为空,且不存在运行时、持久化或已归档 Session,包括没有工作目录的 Session。注册表直接检查持久化历史;仅凭可见侧边栏为空不足以判断。
目录解析器仅在允许创建时于变更队列内运行。它返回绝对路径和初始标题;注册表创建缺失的父目录、规范化路径、重新检查 Session 历史,再一起提交 Workspace 和初始化标记。已存在的目录直接复用;文件冲突或目录操作失败时拒绝初始化。[Host 控制器](../../api/workspace-controller/README.zh.md#first-use-workspace)提供 Documents 路径策略。
目录解析器仅在允许创建时于变更队列内运行。它返回绝对路径;注册表创建缺失的父目录、规范化路径、重新检查 Session 历史,再一起提交 Workspace 和初始化标记,标题取所请求目录(而非规范路径)的最后一段,因此该路径上的符号链接不会让工作区改用链接目标的名称。已存在的目录直接复用;文件冲突或目录操作失败时拒绝初始化。[Host 控制器](../../api/workspace-controller/README.zh.md#first-use-workspace)提供 Documents 路径策略。
首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。目录解析成功后,调用方取消操作不会回滚目录创建或登记。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。
+8 -6
View File
@@ -245,13 +245,15 @@ export class WorkspaceRegistry extends Service {
* Initialize the default Workspace only while both the registry and Session
* history are empty. Repeated requests reuse its durable identity; deleting
* that registration permanently disables automatic creation.
* @param resolveDirectory - resolve the absolute directory and initial title;
* called only for eligible creation, inside the registry mutation queue.
* Missing directories are created recursively before registration.
* @param resolveDirectory - resolve the absolute directory; called only for
* eligible creation, inside the registry mutation queue. Missing directories
* are created recursively before registration, and the initial title is the
* requested directory's own final segment — not the canonical one, so a
* symlink at that path does not retitle the Workspace after its target.
* After resolution, caller cancellation does not roll back creation or registration.
* @returns the initialized Workspace, or undefined when automatic creation is ineligible.
*/
initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise<Workspace | undefined> {
initializeDefault(resolveDirectory: () => Promise<string>): Promise<Workspace | undefined> {
return this.enqueueOperation(async () => {
const state = this.requireState()
if (state.defaultWorkspaceId !== undefined) return this.entities.get(state.defaultWorkspaceId)
@@ -260,13 +262,13 @@ export class WorkspaceRegistry extends Service {
if (state.workspaceIds.length > 0 || state.archivedSessionIds.length > 0
|| sessions.list().length > 0 || (await this.listStoredHeaders()).length > 0) return undefined
const { path, title } = await resolveDirectory()
const path = await resolveDirectory()
if (!fullyQualifiedWorkspacePath(path)) throw new TypeError(`Workspace path is not fully qualified: '${path}'`)
await mkdir(path, { recursive: true })
const canonical = await realpathNormalize(path)
// A Session can start outside the registry queue while directory preparation awaits I/O.
if ((await this.listStoredHeaders()).length > 0 || sessions.list().length > 0) return undefined
return this.createCanonical(canonical, title, true)
return this.createCanonical(canonical, defaultWorkspaceTitle(path), true)
})
}
@@ -1247,7 +1247,7 @@ describe('first-use Workspace preparation', () => {
const directoryRoot = await makeDir('first-use')
const result = await harness({ liveSessions: [], ...options })
contexts.push(result.ctx)
const resolveDirectory = vi.fn(async () => ({ path: join(directoryRoot, 'nested', 'Workspace'), title: 'Workspace' }))
const resolveDirectory = vi.fn(async () => join(directoryRoot, 'nested', 'Workspace'))
return { ...result, directoryRoot, resolveDirectory }
}
@@ -1262,6 +1262,7 @@ describe('first-use Workspace preparation', () => {
expect(h.resolveDirectory).toHaveBeenCalledOnce()
expect(laterDirectory).not.toHaveBeenCalled()
expect(first?.path).toBe(await realpath(join(h.directoryRoot, 'nested', 'Workspace')))
// The caller supplies no title: the initial one is the directory's final segment.
expect(first?.title).toBe('Workspace')
expect(h.registry.list()).toEqual([first])
expect(storedState(h.pool).defaultWorkspaceId).toBe(first?.id)
@@ -1290,7 +1291,7 @@ describe('first-use Workspace preparation', () => {
it('rejects a relative candidate before creating its directory', async () => {
const h = await firstUse()
const candidate = join(h.directoryRoot, 'relative')
h.resolveDirectory.mockResolvedValueOnce({ path: relative(process.cwd(), candidate), title: 'Workspace' })
h.resolveDirectory.mockResolvedValueOnce(relative(process.cwd(), candidate))
await expect(h.registry.initializeDefault(h.resolveDirectory)).rejects.toThrow('fully qualified')
await expect(realpath(candidate)).rejects.toMatchObject({ code: 'ENOENT' })
expect(h.registry.list()).toEqual([])
@@ -1343,6 +1344,19 @@ describe('first-use Workspace preparation', () => {
expect((await h.registry.initializeDefault(h.resolveDirectory))?.path).toBe(path)
})
it('titles the Workspace after the requested directory when that path is a symlink', async () => {
const h = await firstUse()
const target = join(h.directoryRoot, 'elsewhere')
await mkdir(target)
await mkdir(join(h.directoryRoot, 'nested'))
await symlink(target, join(h.directoryRoot, 'nested', 'Workspace'))
const workspace = await h.registry.initializeDefault(h.resolveDirectory)
expect(workspace?.path).toBe(await realpath(target))
// Not 'elsewhere': the title names the directory the caller asked for, so
// the caller can still recognize a Workspace it has not renamed.
expect(workspace?.title).toBe('Workspace')
})
it.each(['persisted', 'live'] as const)('refuses registration when a %s Session appears during directory preparation', async (kind) => {
const h = await firstUse()
const arrived = header('arrived-during-preparation')
-1
View File
@@ -757,7 +757,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
WorkspaceArchiveSessionRequest: 'workspace.md',
WorkspaceArchiveValue: 'workspace.md',
WorkspaceCreateRequest: 'workspace.md',
WorkspaceInitializeDefaultRequest: 'workspace.md',
WorkspaceCreateValue: 'workspace.md',
WorkspaceDeleteRequest: 'workspace.md',
WorkspaceDeleteValue: 'workspace.md',
+1
View File
@@ -101,6 +101,7 @@
"@deepseek-ai/dsh-api-settings-controller/types": ["./packages/api/settings-controller/src/types.ts"],
"@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"],
"@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"],
"@deepseek-ai/dsh-api-workspace-controller/default-workspace": ["./packages/api/workspace-controller/src/default-workspace.ts"],
"@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"],
"@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"],
"@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"],