Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading
# Conflicts: # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml # .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md # docs/user/develop/basic/index.i18n.yaml # docs/user/develop/basic/index.zh.md # docs/user/guide/config.i18n.yaml # docs/user/guide/config.zh.md # docs/user/guide/providers.i18n.yaml # docs/user/guide/providers.zh.md # packages/bundle/base/README.i18n.yaml # packages/bundle/base/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.zh.md
This commit is contained in:
@@ -199,6 +199,74 @@ describe('Loader config interpolation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loader entry disabled interpolation', () => {
|
||||
it('evaluates a !!js disabled expression against the loader context', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: expr-off',
|
||||
' name: ./noop.mjs',
|
||||
' disabled: !!js process.version.length > 0',
|
||||
'- id: expr-on',
|
||||
' name: ./noop.mjs',
|
||||
' disabled: !!js process.version.length === 0',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const off = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-off')
|
||||
const on = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-on')
|
||||
expect(off?.disabled).toBe(true)
|
||||
expect(off?.fiber).toBeUndefined()
|
||||
expect(on?.disabled).toBe(false)
|
||||
expect(on?.fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the raw expression in the options so write-back preserves the !!js form', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.platform === "win32"\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
|
||||
// The evaluated boolean drives the mount decision; the serialized
|
||||
// expression node stays in the options for the file-backed tree.
|
||||
expect(entry?.options.disabled).toEqual({ __jsExpr: 'process.platform === "win32"' })
|
||||
expect(entry?.disabled).toBe(process.platform === 'win32')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-evaluates when update() replaces the expression, mounting and unmounting', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.version.length === 0\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
|
||||
expect(entry?.disabled).toBe(false)
|
||||
expect(entry?.fiber).toBeDefined()
|
||||
// The expression form is the file dialect; the typed programmatic API
|
||||
// carries booleans. Include reapplication feeds the raw node through
|
||||
// the untyped file path — simulated here with the serialized shape.
|
||||
const disabledTrue = { __jsExpr: 'process.version.length > 0' } as unknown as boolean
|
||||
const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean
|
||||
await entry?.update({ disabled: disabledTrue })
|
||||
expect(entry?.disabled).toBe(true)
|
||||
expect(entry?.fiber).toBeUndefined()
|
||||
await entry?.update({ disabled: disabledFalse })
|
||||
expect(entry?.disabled).toBe(false)
|
||||
expect(entry?.fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with user patches', () => {
|
||||
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
||||
const dir = tmp()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/base/README.md
|
||||
README.md: 8b0db20274036a2601da19617a35e6bf4aeb30ca
|
||||
README.zh.md: ba3b792ababbe0c93def20430852b2f16fbe6151
|
||||
README.md: bd38f39f58ee1f765ff34d40cf57cc6daed2b32b
|
||||
README.zh.md: 7f6e562cd56dd21dac2c49a1ae9a82f033a653e1
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
|
||||
|
||||
Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it.
|
||||
The patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled.
|
||||
|
||||
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。
|
||||
|
||||
启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不受沙盒约束的本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时直接报错)。POSIX 主机永远不会收到它。
|
||||
patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时直接报错)。POSIX 主机看到的是被禁用的 pwsh 行。
|
||||
|
||||
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
|
||||
|
||||
|
||||
@@ -172,9 +172,14 @@
|
||||
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
disabled: !!js process.platform === 'win32'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
disabled: !!js process.platform !== 'win32'
|
||||
|
||||
- id: approval
|
||||
name: '@deepseek-ai/dsh-user-approval'
|
||||
config:
|
||||
@@ -199,6 +204,11 @@
|
||||
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
disabled: !!js process.platform === 'win32'
|
||||
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
disabled: !!js process.platform !== 'win32'
|
||||
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./windows.cordis.patch.yml": "./windows.cordis.patch.yml",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -31,7 +30,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"cordis.patch.yml",
|
||||
"windows.cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* field must name a real, parseable patch list.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
|
||||
|
||||
describe('dsh-base bundle', () => {
|
||||
it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
|
||||
@@ -39,34 +40,37 @@ describe('dsh-base bundle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {
|
||||
it('gates each shell stack by platform with a symmetric disabled expression', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const parsed = yaml.load(
|
||||
readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'),
|
||||
readFileSync(resolve(root, 'cordis.patch.yml'), 'utf8'),
|
||||
{ schema: entryListSchema },
|
||||
) as {
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
insert?: { id?: string; name?: string }[]
|
||||
config?: { policy?: string }
|
||||
}[]
|
||||
const disables = parsed
|
||||
.filter(patch => patch.disabled === true)
|
||||
.map(patch => patch.id)
|
||||
// Only the POSIX bash stack is disabled: the Windows roster confines the
|
||||
// pwsh executor through the ACL runner chain, so the sandbox/policy rows,
|
||||
// the permission switcher, fs-sandbox, and the approval service all stay
|
||||
// enabled exactly as on POSIX — only the shell is swapped.
|
||||
expect(disables).toEqual(['bash-sandbox', 'tool-bash'])
|
||||
const inserted = parsed
|
||||
.flatMap(patch => patch.insert ?? [])
|
||||
.map(row => row.id)
|
||||
expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh'])
|
||||
// The patch no longer touches the permission/approval surface at all.
|
||||
expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined()
|
||||
)
|
||||
if (!Array.isArray(parsed)) throw new TypeError('base patch must parse to a patch list')
|
||||
const rows = parsed.flatMap((patch): Record<string, unknown>[] =>
|
||||
typeof patch === 'object' && patch !== null
|
||||
? (patch as { insert?: Record<string, unknown>[] }).insert ?? []
|
||||
: [],
|
||||
)
|
||||
// Symmetric gating: each stack's executor and tool rows carry the same
|
||||
// platform fact, inverted between the bash and pwsh twins, so exactly one
|
||||
// shell stack mounts per host. Evaluate with a platform-scoped context
|
||||
// (the `with` scope shadows the global `process`) so both outcomes pin on
|
||||
// every host.
|
||||
for (const [id, win32, linux] of [
|
||||
['bash-sandbox', true, false],
|
||||
['tool-bash', true, false],
|
||||
['pwsh-sandbox', false, true],
|
||||
['tool-pwsh', false, true],
|
||||
] as const) {
|
||||
const row = rows.find(candidate => candidate.id === id)
|
||||
if (row === undefined) throw new Error(`base patch must mount ${id}`)
|
||||
const expression = (row.disabled as { __jsExpr?: string } | undefined)?.__jsExpr
|
||||
if (expression === undefined) throw new Error(`${id} must gate on a !!js disabled expression`)
|
||||
expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
|
||||
expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(linux)
|
||||
}
|
||||
// The platform layer folded into these rows: no separate patch file ships.
|
||||
expect(existsSync(resolve(root, 'windows.cordis.patch.yml'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# The dsh-base Windows platform layer: applied by the dsh launcher on win32
|
||||
# hosts, between the bundle layers and the user layers. Windows confines
|
||||
# through the ACL restricted-token runner (the win32 chain of
|
||||
# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped
|
||||
# stack is the SANDBOXED PowerShell executor plus the full permission
|
||||
# surface: sandbox/sandbox-policy enforce the file-effect policy, the
|
||||
# permission switcher and the approval service run exactly as on POSIX, and
|
||||
# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting
|
||||
# dsh-fs-local alongside it would double-register ctx.fs and fail the load.
|
||||
# Only the POSIX bash
|
||||
# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner.
|
||||
# A Windows host that prefers the unconfined local pwsh executor or full
|
||||
# access overrides these rows through its profile or home cordis.patch.yml.
|
||||
# The bash-restore recipe must be complete: disable pwsh-sandbox and
|
||||
# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor
|
||||
# families register the same 'bash' service, so re-enabling the bash rows
|
||||
# while pwsh-sandbox stays inserted fails loud at load on a duplicate
|
||||
# registration.
|
||||
|
||||
- id: bash-sandbox
|
||||
disabled: true
|
||||
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
@@ -262,6 +262,9 @@
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-pwsh
|
||||
disabled: true
|
||||
|
||||
# The background-task REGISTRY stays on the host plane; only the model-facing
|
||||
# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a
|
||||
# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it
|
||||
@@ -374,12 +377,15 @@
|
||||
disabled: true
|
||||
|
||||
# The preset roster. `config/agent-presets/` ships with the deployment and is
|
||||
# read-only (its entries carry `system` trust);
|
||||
# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and
|
||||
# carries the same trust as shell access because a preset IS a composition.
|
||||
# `roots` is an assembly fact, not user config: the shipped preset directory
|
||||
# ships beside this file, so AppCLIEntry resolves it and patches it in — the
|
||||
# same treatment `distIndex` gets on the webserver row.
|
||||
# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is
|
||||
# where a person — or an agent — authors their own, and carries the same trust
|
||||
# as shell access because a preset IS a composition.
|
||||
#
|
||||
# Only the SHIPPED root is an assembly fact: it sits beside the installed app's
|
||||
# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the
|
||||
# same treatment `distIndex` gets on the webserver row. The writable root is
|
||||
# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that
|
||||
# never reaches that patch still finds a person's presets.
|
||||
- insert:
|
||||
- id: agent-presets
|
||||
name: '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
@@ -2513,6 +2513,38 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
emitHost({ type: 'host/workspace-removed', workspaceId })
|
||||
return ok(request, { deleted: true as const })
|
||||
},
|
||||
insertBefore: (request) => {
|
||||
const { workspaceId, beforeWorkspaceId } = request.payload
|
||||
const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
||||
const anchor = beforeWorkspaceId === undefined
|
||||
? workspaces.length
|
||||
: workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId)
|
||||
const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined
|
||||
if (missing !== undefined) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${missing}`,
|
||||
details: { workspaceId: missing },
|
||||
})
|
||||
}
|
||||
if (beforeWorkspaceId !== workspaceId) {
|
||||
const previousOrder = workspaces.map(candidate => candidate.workspaceId)
|
||||
const [workspace] = workspaces.splice(source, 1)
|
||||
/* v8 ignore next -- source was resolved from the same array immediately above. */
|
||||
if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`)
|
||||
const at = beforeWorkspaceId === undefined
|
||||
? workspaces.length
|
||||
: workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId)
|
||||
workspaces.splice(at, 0, workspace)
|
||||
if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) {
|
||||
emitHost({
|
||||
type: 'host/workspace-order-changed',
|
||||
workspaceIds: workspaces.map(candidate => candidate.workspaceId),
|
||||
})
|
||||
}
|
||||
}
|
||||
return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) })
|
||||
},
|
||||
insertSessionBefore: (request) => {
|
||||
const { workspaceId, sessionId, beforeSessionId } = request.payload
|
||||
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
|
||||
@@ -2959,6 +2991,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
case 'workspace.delete': return this.api.workspace.delete(request)
|
||||
case 'workspace.insertBefore': return this.api.workspace.insertBefore(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -158,6 +158,9 @@ export class FakeApiClient implements IApiClient {
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
|
||||
insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({
|
||||
workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId],
|
||||
}))),
|
||||
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
|
||||
@@ -264,7 +264,12 @@ describe('createFixtureApi', () => {
|
||||
await consuming
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const createdId = created.result.value.sessionId
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }])
|
||||
expect(seen).toHaveLength(1)
|
||||
const added = seen[0]
|
||||
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
|
||||
expect(added).toEqual({
|
||||
type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture',
|
||||
})
|
||||
const list = await api.sessions.list(req({}))
|
||||
if (!list.result.ok) throw new Error('list failed')
|
||||
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
|
||||
@@ -699,7 +704,11 @@ describe('createFixtureApi', () => {
|
||||
await consuming
|
||||
// The session lands with the workspace's path as cwd, and the account
|
||||
// write pushes the fresh workspace snapshot after session-added.
|
||||
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' })
|
||||
const added = seen[0]
|
||||
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
|
||||
expect(added).toEqual({
|
||||
type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture',
|
||||
})
|
||||
expect(seen[1]).toMatchObject({
|
||||
type: 'host/workspace-changed',
|
||||
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
|
||||
@@ -728,7 +737,12 @@ describe('createFixtureApi', () => {
|
||||
expect(frames[0]).toMatchObject({
|
||||
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
|
||||
})
|
||||
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path })
|
||||
const added = frames[1]
|
||||
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
|
||||
expect(added).toEqual({
|
||||
type: 'host/session-added', sessionId: preallocated, blank: true,
|
||||
cwd: made.result.value.workspace.path,
|
||||
})
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: f4823f58ec79df0cbccfff0a08d9bb59b9a3ac8d
|
||||
README.zh.md: c60f0be261bae6123e2202e4a0e05e556723e5e9
|
||||
README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446
|
||||
README.zh.md: 46120665e2720df7c0331b6bdea957316476a4a6
|
||||
|
||||
@@ -16,7 +16,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
|
||||
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
|
||||
|
||||
@@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
|
||||
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。
|
||||
|
||||
@@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ export interface IWorkspaces {
|
||||
*/
|
||||
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
|
||||
/**
|
||||
* The New Session flow: connect the target (or recent) Workspace and open
|
||||
* the resulting session; failures surface on the session list state.
|
||||
* @param workspaceId - explicit target; omitted uses the recency projection.
|
||||
* The New Session flow: connect the explicit, current-Session, or recent
|
||||
* Workspace and open the resulting session; failures surface on the session
|
||||
* list state.
|
||||
* @param workspaceId - explicit target; omitted inherits the current
|
||||
* Session's Workspace before falling back to the recency projection.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId): void
|
||||
/**
|
||||
@@ -68,6 +70,12 @@ export interface IWorkspaces {
|
||||
* @param workspaceId - target workspace.
|
||||
*/
|
||||
delete(workspaceId: WorkspaceId): Promise<void>
|
||||
/**
|
||||
* Move a Workspace within the registry display order.
|
||||
* @param workspaceId - Workspace to move.
|
||||
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
|
||||
*/
|
||||
insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void>
|
||||
/**
|
||||
* Move an accounted session within/into a Workspace's ordered list.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -72,6 +72,7 @@ type SessionListMutation =
|
||||
| { kind: 'upsert'; summary: SessionSummary }
|
||||
| { kind: 'remove'; sessionId: SessionId }
|
||||
| { kind: 'status'; sessionId: SessionId; running: boolean }
|
||||
| { kind: 'activity'; sessionId: SessionId; updatedAt: number }
|
||||
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
|
||||
| { kind: 'engaged'; sessionId: SessionId }
|
||||
|
||||
@@ -682,6 +683,16 @@ export class SessionManager {
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
||||
if (
|
||||
frame.type === 'session/event'
|
||||
&& frame.event.type === 'user/message'
|
||||
&& frame.event.data.source.kind === 'user'
|
||||
) {
|
||||
// session.list supplies the cold baseline, while a direct prompt or an
|
||||
// admitted steer advances it between pulls. Max keeps replayed or
|
||||
// repaired older user messages from moving the row backwards.
|
||||
this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time })
|
||||
}
|
||||
if (frame.type === 'session/projection') {
|
||||
// Finished host-computed value: land it in the resident store whether or
|
||||
// not the Session is instantiated (list rows read the 'title' key). The
|
||||
@@ -1101,6 +1112,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
|
||||
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
|
||||
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
|
||||
: summary)
|
||||
case 'activity':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
||||
&& mutation.updatedAt > summary.updatedAt
|
||||
? { ...summary, updatedAt: mutation.updatedAt }
|
||||
: summary)
|
||||
case 'engaged':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
|
||||
? { ...summary, blank: false }
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
|
||||
|
||||
@@ -30,6 +29,7 @@ export interface WorkspaceListSnapshot {
|
||||
type WorkspaceDelta =
|
||||
| { type: 'upsert'; workspace: WorkspaceView }
|
||||
| { type: 'remove'; workspaceId: WorkspaceId }
|
||||
| { type: 'order'; workspaceIds: readonly WorkspaceId[] }
|
||||
|
||||
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
|
||||
export class WorkspaceManager {
|
||||
@@ -51,6 +51,12 @@ export class WorkspaceManager {
|
||||
* mirror of replaying refreshFrames over the item baseline.
|
||||
*/
|
||||
private archivedSupersedesRefresh = false
|
||||
/** Latest local reorder request; only its unary echo may install order. */
|
||||
private orderRequestGeneration = 0
|
||||
/** Increments on order frames so a later remote commit outranks an older unary echo. */
|
||||
private orderFrameGeneration = 0
|
||||
/** Last complete order accepted from a Host baseline, frame, or current unary echo. */
|
||||
private committedOrder: WorkspaceId[] = []
|
||||
/**
|
||||
* Ids this process has seen removed, kept for the connection's lifetime so
|
||||
* a late changed frame or a stale baseline row cannot resurrect a deleted
|
||||
@@ -72,16 +78,15 @@ export class WorkspaceManager {
|
||||
|
||||
/**
|
||||
* Refresh from workspace.list. The first successful response establishes
|
||||
* Host order; later responses update membership and values without moving
|
||||
* identities already visible to the client. Frames arriving during the RPC
|
||||
* are replayed over its response.
|
||||
* Host order; later responses re-establish the durable order so reconnects
|
||||
* adopt reorders committed while this client was offline. Frames arriving
|
||||
* during the RPC are replayed over its response.
|
||||
* @returns the shared in-flight refresh.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
if (this.inflight !== null) return this.inflight
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
const established = this.itemViews()
|
||||
const frames: WorkspaceDelta[] = []
|
||||
this.refreshFrames = frames
|
||||
this.notifier.markDirty()
|
||||
@@ -89,9 +94,7 @@ export class WorkspaceManager {
|
||||
try {
|
||||
const { result } = await this.api.workspace.list({})
|
||||
if (result.ok) {
|
||||
let items = this.phase === 'pending'
|
||||
? result.value.items
|
||||
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
|
||||
let items = result.value.items
|
||||
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
|
||||
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
|
||||
this.installViews(items)
|
||||
@@ -157,6 +160,44 @@ export class WorkspaceManager {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a Workspace within the registry display order and install the full
|
||||
* returned order without waiting for the Host frame.
|
||||
* @param workspaceId - Workspace to move.
|
||||
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async insertBefore(
|
||||
workspaceId: WorkspaceId,
|
||||
beforeWorkspaceId?: WorkspaceId,
|
||||
): Promise<RpcResult<{ workspaceIds: WorkspaceId[] }>> {
|
||||
const requestGeneration = ++this.orderRequestGeneration
|
||||
const frameGeneration = this.orderFrameGeneration
|
||||
const localOrder = this.itemViews().map(workspace => workspace.workspaceId)
|
||||
this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
|
||||
let result: RpcResult<{ workspaceIds: WorkspaceId[] }>
|
||||
try {
|
||||
;({ result } = await this.api.workspace.insertBefore({
|
||||
workspaceId,
|
||||
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
|
||||
}))
|
||||
} catch (error) {
|
||||
if (requestGeneration === this.orderRequestGeneration
|
||||
&& frameGeneration === this.orderFrameGeneration) {
|
||||
this.installOrder(this.committedOrder)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (result.ok && requestGeneration === this.orderRequestGeneration
|
||||
&& frameGeneration === this.orderFrameGeneration) {
|
||||
this.installOrder(result.value.workspaceIds, true)
|
||||
} else if (!result.ok && requestGeneration === this.orderRequestGeneration
|
||||
&& frameGeneration === this.orderFrameGeneration) {
|
||||
this.installOrder(this.committedOrder)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order, then publish the
|
||||
* returned snapshot without waiting for the changed frame.
|
||||
@@ -198,6 +239,10 @@ export class WorkspaceManager {
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
|
||||
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
|
||||
else if (envelope.payload.type === 'host/workspace-order-changed') {
|
||||
this.orderFrameGeneration++
|
||||
this.installOrder(envelope.payload.workspaceIds, true)
|
||||
}
|
||||
else if (envelope.payload.type === 'host/archived-sessions-changed') {
|
||||
this.installArchived(envelope.payload.archivedSessionIds)
|
||||
}
|
||||
@@ -249,6 +294,24 @@ export class WorkspaceManager {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Reorder known Workspace objects, optionally recording a Host-committed sequence. */
|
||||
private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
|
||||
if (committed) {
|
||||
this.refreshFrames?.push({ type: 'order', workspaceIds })
|
||||
this.committedOrder = [...workspaceIds]
|
||||
}
|
||||
const rank = new Map(workspaceIds.map((id, index) => [id, index]))
|
||||
const items = [...this.items].sort((left, right) => {
|
||||
const leftId = left.getSnapshot().view?.workspaceId
|
||||
const rightId = right.getSnapshot().view?.workspaceId
|
||||
return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
if (items.every((item, index) => item === this.items[index])) return
|
||||
this.items = items
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Upsert one Host view, optionally retaining the local object that materialized it. */
|
||||
private upsert(view: WorkspaceView, identity?: Workspace): void {
|
||||
if (this.removedIds.has(view.workspaceId)) return
|
||||
@@ -259,6 +322,9 @@ export class WorkspaceManager {
|
||||
// late unary response cannot roll back a newer frame.
|
||||
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
|
||||
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
|
||||
if (!this.committedOrder.includes(view.workspaceId)) {
|
||||
this.committedOrder = [view.workspaceId, ...this.committedOrder]
|
||||
}
|
||||
if (identity !== undefined) {
|
||||
this.items = index === -1
|
||||
? [identity, ...this.items]
|
||||
@@ -276,6 +342,7 @@ export class WorkspaceManager {
|
||||
private remove(workspaceId: WorkspaceId, direct = false): void {
|
||||
this.refreshFrames?.push({ type: 'remove', workspaceId })
|
||||
this.removedIds.add(workspaceId)
|
||||
this.committedOrder = this.committedOrder.filter(id => id !== workspaceId)
|
||||
const items = this.items.filter(item =>
|
||||
item.getSnapshot().view?.workspaceId !== workspaceId)
|
||||
if (items.length === this.items.length) {
|
||||
@@ -309,6 +376,7 @@ export class WorkspaceManager {
|
||||
installed.set(view.workspaceId, workspace)
|
||||
}
|
||||
this.items = [...installed.values()]
|
||||
this.committedOrder = views.map(view => view.workspaceId)
|
||||
}
|
||||
|
||||
private itemViews(): readonly WorkspaceView[] {
|
||||
@@ -332,7 +400,26 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
|
||||
|
||||
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
|
||||
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
|
||||
return delta.type === 'upsert'
|
||||
? upsertWorkspace(items, delta.workspace)
|
||||
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
|
||||
if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace)
|
||||
if (delta.type === 'remove') {
|
||||
return items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
|
||||
}
|
||||
const rank = new Map(delta.workspaceIds.map((id, index) => [id, index]))
|
||||
return [...items].sort((left, right) =>
|
||||
(rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER))
|
||||
}
|
||||
|
||||
/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */
|
||||
function insertIdBefore(
|
||||
ids: readonly WorkspaceId[],
|
||||
id: WorkspaceId,
|
||||
beforeId?: WorkspaceId,
|
||||
): WorkspaceId[] {
|
||||
if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) {
|
||||
return [...ids]
|
||||
}
|
||||
const without = ids.filter(candidate => candidate !== id)
|
||||
const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
|
||||
return [...without.slice(0, at), id, ...without.slice(at)]
|
||||
}
|
||||
|
||||
@@ -167,14 +167,20 @@ export class WorkspacesService implements IWorkspaces {
|
||||
/**
|
||||
* The shared New Session action behind the shell entry points (sidebar
|
||||
* button, workspace browser): resolve the target Workspace — explicit wins,
|
||||
* else the recent-Workspace projection — connect its blank session and
|
||||
* navigate there; with no Workspace at all, clear the selection into the
|
||||
* New Session view state. Connect failures are non-fatal (console
|
||||
* diagnostics; the current view stays usable).
|
||||
* then the current Session's Workspace, then the recent-Workspace
|
||||
* projection — connect its blank session and navigate there; with no
|
||||
* Workspace at all, clear the selection into the New Session view state.
|
||||
* Connect failures are non-fatal (console diagnostics; the current view
|
||||
* stays usable).
|
||||
* @param workspaceId - explicit target Workspace for scoped actions.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId): void {
|
||||
const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId
|
||||
const workspace = this.list.getSnapshot()
|
||||
const current = this.sessions.list.getSnapshot().current
|
||||
const currentWorkspaceId = current === undefined
|
||||
? undefined
|
||||
: workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
|
||||
const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId
|
||||
if (target === undefined) {
|
||||
this.sessions.clear()
|
||||
return
|
||||
@@ -265,6 +271,16 @@ export class WorkspacesService implements IWorkspaces {
|
||||
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a Workspace within the durable registry display order.
|
||||
* @param workspaceId - Workspace to move.
|
||||
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
|
||||
*/
|
||||
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
|
||||
const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId)
|
||||
if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a session into the registry-global set. Clearing an archived
|
||||
* current selection is the projection sweep's job (one rule for the local
|
||||
|
||||
@@ -195,6 +195,9 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
|
||||
() => Promise.resolve(ok({ deleted: true }))
|
||||
|
||||
onWorkspaceInsertBefore: (payload: unknown) => Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>> =
|
||||
() => Promise.resolve(ok({ workspaceIds: [] }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
@@ -210,6 +213,8 @@ export class FakeApiClient implements IApiClient {
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
|
||||
insertBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
archiveSession: (payload: unknown) =>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { entries, plainTurn } from './event-script.client.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.client.ts'
|
||||
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
const S2 = 'fk-m2' as SessionId
|
||||
@@ -113,6 +113,46 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
|
||||
})
|
||||
|
||||
it('advances list activity only for direct user messages', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
|
||||
// Both a new prompt and an admitted steer land as a user-sourced message.
|
||||
const activity = { ...ev.user(10, 'new'), time: 500 }
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'activity' as never,
|
||||
payload: { type: 'session/event', sessionId: S1, event: activity },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'older' as never,
|
||||
payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'assistant' as never,
|
||||
payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } },
|
||||
})
|
||||
|
||||
const injected = ev.user(12, 'context')
|
||||
if (injected.type !== 'user/message') throw new Error('user builder returned another event type')
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'injected' as never,
|
||||
payload: {
|
||||
type: 'session/event',
|
||||
sessionId: S1,
|
||||
event: {
|
||||
...injected,
|
||||
time: 700,
|
||||
data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
|
||||
})
|
||||
|
||||
it('keeps the error in the list snapshot on failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
@@ -17,7 +17,7 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0
|
||||
}
|
||||
|
||||
describe('WorkspaceManager', () => {
|
||||
it('replays changed frames over hydration and keeps established order on refresh', async () => {
|
||||
it('replays changed frames over hydration and adopts the durable order on refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
@@ -36,7 +36,7 @@ describe('WorkspaceManager', () => {
|
||||
items: [workspace('old'), workspace('new')] as never[],
|
||||
}))
|
||||
await manager.refresh()
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new'])
|
||||
})
|
||||
|
||||
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
|
||||
@@ -77,6 +77,73 @@ describe('WorkspaceManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two'), workspace('three')] as never[],
|
||||
}))
|
||||
const manager = new WorkspaceManager(api)
|
||||
await manager.refresh()
|
||||
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
|
||||
api.onWorkspaceInsertBefore = () => gate.promise
|
||||
const pending = manager.insertBefore(wid('three'), wid('one'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'newer-order' as never,
|
||||
payload: {
|
||||
type: 'host/workspace-order-changed',
|
||||
workspaceIds: [wid('one'), wid('three'), wid('two')],
|
||||
},
|
||||
})
|
||||
gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
|
||||
await pending
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' },
|
||||
}))
|
||||
const rejected = manager.insertBefore(wid('three'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
|
||||
await expect(rejected).resolves.toMatchObject({ ok: false })
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||
|
||||
api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down'))
|
||||
const disconnected = manager.insertBefore(wid('three'), wid('one'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
|
||||
await expect(disconnected).rejects.toThrow('transport down')
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||
})
|
||||
|
||||
it('rolls overlapping rejected reorders back to the last Host-confirmed order', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two'), workspace('three')] as never[],
|
||||
}))
|
||||
const manager = new WorkspaceManager(api)
|
||||
await manager.refresh()
|
||||
const firstGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
|
||||
const secondGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
|
||||
let request = 0
|
||||
api.onWorkspaceInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
|
||||
|
||||
const first = manager.insertBefore(wid('three'), wid('one'))
|
||||
const second = manager.insertBefore(wid('two'), wid('three'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
|
||||
|
||||
firstGate.resolve(err({
|
||||
code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: 'three' },
|
||||
}))
|
||||
await expect(first).resolves.toMatchObject({ ok: false })
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
|
||||
|
||||
secondGate.resolve(err({
|
||||
code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: 'two' },
|
||||
}))
|
||||
await expect(second).resolves.toMatchObject({ ok: false })
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
|
||||
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
@@ -309,6 +376,72 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two')] as never[],
|
||||
}))
|
||||
await workspaces.refresh()
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(ok({
|
||||
workspaceIds: [wid('two'), wid('one')],
|
||||
}))
|
||||
await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
|
||||
expect(api.callsOf('workspace.insertBefore')).toEqual([{
|
||||
workspaceId: 'two', beforeWorkspaceId: 'one',
|
||||
}])
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
|
||||
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
|
||||
}))
|
||||
await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
workspace('current-home', [sid('current')]),
|
||||
workspace('recent-home', [sid('recent')]),
|
||||
] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({ items: [
|
||||
{ sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
|
||||
{ sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
|
||||
] as never[] }))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
sessions.open(sid('current'))
|
||||
const unresolved = new Promise<SessionId>(() => {})
|
||||
const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
|
||||
|
||||
workspaces.startSession(wid('recent-home'))
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
|
||||
|
||||
workspaces.startSession()
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('current-home'))
|
||||
|
||||
sessions.clear()
|
||||
workspaces.startSession()
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
|
||||
|
||||
const emptyCtx = new Context()
|
||||
const emptyApi = new FakeApiClient()
|
||||
const emptySessions = new SessionsService(emptyCtx, emptyApi, fakeRemote())
|
||||
const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions)
|
||||
const clear = vi.spyOn(emptySessions, 'clear')
|
||||
emptyWorkspaces.startSession()
|
||||
expect(clear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -172,6 +172,16 @@ export class TestWorkspaces implements IWorkspaces {
|
||||
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a Workspace in display order (recorded; default no-op).
|
||||
* @param workspaceId - Workspace to move.
|
||||
* @param beforeWorkspaceId - Anchor; omitted appends.
|
||||
*/
|
||||
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
|
||||
this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] })
|
||||
await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise<void> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an accounted session (recorded). The default echoes a minimal view.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -578,6 +578,7 @@ describe('workspaces action face', () => {
|
||||
expect(renamed.title).toBe('Renamed')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/proj/file.ts')
|
||||
await ws.insertBefore('w1' as WorkspaceId, 'w2' as WorkspaceId)
|
||||
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
|
||||
expect(moved.sessionIds).toEqual(['s1'])
|
||||
// Default archive mirrors the production effect: the id joins the list
|
||||
@@ -585,13 +586,15 @@ describe('workspaces action face', () => {
|
||||
await ws.archiveSession('s1' as SessionId)
|
||||
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
|
||||
expect(ws.calls.map(c => c.method)).toEqual(
|
||||
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
|
||||
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertBefore', 'insertSessionBefore', 'archiveSession'])
|
||||
|
||||
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
|
||||
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
|
||||
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
|
||||
ws.stub('delete', () => Promise.resolve())
|
||||
ws.stub('openPath', () => Promise.resolve())
|
||||
const insertBefore = vi.fn(() => Promise.resolve())
|
||||
ws.stub('insertBefore', insertBefore)
|
||||
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
|
||||
ws.stub('archiveSession', () => Promise.resolve())
|
||||
expect((await ws.create({ path: '/y' })).title).toBe('X')
|
||||
@@ -599,6 +602,8 @@ describe('workspaces action face', () => {
|
||||
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/other')
|
||||
await ws.insertBefore('w2' as WorkspaceId)
|
||||
expect(insertBefore).toHaveBeenCalledWith('w2', undefined)
|
||||
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
|
||||
// The stub replaces the default set mutation: the set stays as-is.
|
||||
await ws.archiveSession('s2' as SessionId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Conversation column skeleton: header (breadcrumb row only for subagents not fork + tabs) over the view
|
||||
area, composer InputBar at the bottom. Column width/squeeze is layout's;
|
||||
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
|
||||
a 3px active bar. */
|
||||
a 2px active bar. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
@@ -26,9 +26,22 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
flex: none;
|
||||
padding: 12px 28px 0 20px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 1px;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
height: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Blank hero/settling: keep the strict Session header mounted without taking
|
||||
@@ -100,13 +113,15 @@
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
.tabs {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 36px;
|
||||
margin-top: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
|
||||
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */
|
||||
.tab {
|
||||
position: relative;
|
||||
padding: 0 0 11px;
|
||||
@@ -123,9 +138,10 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
bottom: 1px;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,9 @@ export function HeroShell({ t, children }: HeroShellProps) {
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
<span className={css.fishHitbox}>
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
</span>
|
||||
<span className={css.headlineText}>{t('hero.headline')}</span>
|
||||
<span className={css.previewBadge}>{t('hero.preview')}</span>
|
||||
</div>
|
||||
|
||||
@@ -61,11 +61,39 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* figma fish fill rides business blue. */
|
||||
.fish {
|
||||
/* Keep hover detection on a stationary box while the mark moves within it. */
|
||||
.fishHitbox {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Keep the hero mark in the same primary ink as its headline. */
|
||||
.fish {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
transform-origin: 50% 60%;
|
||||
}
|
||||
|
||||
@keyframes hero-fish-swim {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
|
||||
35% {
|
||||
transform: translate(-1px, -1px) rotate(-5deg);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(1px, 0) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
|
||||
.fishHitbox:hover .fish {
|
||||
animation: hero-fish-swim var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
|
||||
|
||||
@@ -20,10 +20,10 @@ export interface Columns { sidebar: number; center: number; details: number }
|
||||
/** Center column floor; only the final fallback may go below it. */
|
||||
export const CENTER_MIN = 640
|
||||
/** Sidebar drag clamp floor. */
|
||||
export const SIDEBAR_MIN = 280
|
||||
export const SIDEBAR_MIN = 264
|
||||
/** Sidebar drag clamp ceiling. */
|
||||
export const SIDEBAR_MAX = 420
|
||||
/** Sidebar width before any user drag (= the drag floor). */
|
||||
/** Sidebar width before any user drag. */
|
||||
export const SIDEBAR_DEFAULT = 280
|
||||
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
|
||||
export const SIDEBAR_COLLAPSED = 56
|
||||
|
||||
@@ -115,6 +115,15 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.denseList .item {
|
||||
min-height: 34px;
|
||||
padding-block: 5px;
|
||||
}
|
||||
|
||||
.denseList .label {
|
||||
padding-block: 4px;
|
||||
}
|
||||
|
||||
.list.compactList,
|
||||
.submenu.compactList {
|
||||
min-width: 164px;
|
||||
|
||||
@@ -62,6 +62,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* @param props.anchor - the trigger element (rendered in place).
|
||||
* @param props.items - selectable rows and optional separators.
|
||||
* @param props.selectedId - row shown as selected.
|
||||
* @param props.selectedIds - rows shown as selected when a menu contains independent option groups.
|
||||
* @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children).
|
||||
* @param props.onClose - invoked on outside click or Escape.
|
||||
* @param props.align - list alignment against the anchor (default 'start').
|
||||
@@ -74,6 +75,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* both trigger and list for the pointer grace (default false keeps it open
|
||||
* until outside click/Escape/selection). The grace makes the 4px trigger->list
|
||||
* gap and a brief overshoot survivable; coming back cancels the close.
|
||||
* @param props.dense - reduce vertical row spacing without changing the standard typography or card width.
|
||||
* @param props.compact - use reduced menu typography and spacing.
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
@@ -85,18 +87,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* by a hairline; they stay visible while the items above scroll.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
footer?: readonly MenuEntry[]
|
||||
selectedId?: string | undefined
|
||||
selectedIds?: readonly string[] | undefined
|
||||
onSelect: (id: string) => void
|
||||
onClose: () => void
|
||||
align?: 'start' | 'end'
|
||||
side?: 'bottom' | 'top' | 'right'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
dense?: boolean
|
||||
compact?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
className?: string
|
||||
@@ -204,6 +208,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
}
|
||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||
const subOpen = hasSub && openSubmenuId === entry.id
|
||||
const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
@@ -214,7 +219,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
||||
className={clsx(css.item, selected && css.selected, entry.danger === true && css.danger)}
|
||||
disabled={entry.disabled}
|
||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
@@ -230,7 +235,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
{selected && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
|
||||
@@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
const list = open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
className={clsx(css.list, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
// React portals bubble synthetic events through the REACT tree: without
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar
|
||||
foot trigger row + centered 1080x700 modal panel. The trigger reproduces
|
||||
the former sidebar foot geometry (49px wide row / 36px rail circle); the
|
||||
foot trigger row + centered 1080x700 modal panel. The trigger uses the
|
||||
sidebar's 34px compact row / 36px rail circle rhythm; the
|
||||
panel is a two-column layout — 188px nav rail + content column with a
|
||||
54px header and the 24px-padded options area. */
|
||||
|
||||
/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */
|
||||
/* Trigger row: match the other wide sidebar controls' compact vertical rhythm. */
|
||||
.trigger {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 49px;
|
||||
margin: 8px 0 0;
|
||||
padding: 0 2px 0 6px;
|
||||
width: calc(100% + 8px);
|
||||
height: 34px;
|
||||
margin: 4px -4px 4px;
|
||||
padding: 6px 2px 6px 10px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
@@ -22,6 +23,7 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
@@ -32,7 +34,7 @@
|
||||
.trigger.rail {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 18px 0 10px;
|
||||
margin: 8px 0 10px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
|
||||
README.md: 4eb9eeb73f1f8398eb9d16434996840182ba79a9
|
||||
README.zh.md: a9fb927305d0bab5fb4d27adbfdbec90dfa1dd6d
|
||||
README.md: 9974118f69901de985e012e1b62f95a0bcee64c2
|
||||
README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
Sidebar shell plugin: the wordmark, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar.
|
||||
New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace.
|
||||
|
||||
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state.
|
||||
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store.
|
||||
|
||||
Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows.
|
||||
|
||||
@@ -25,5 +25,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — no done/error notification sources are available.
|
||||
- **Group-by supports Workspace only** — Update and Status are not available strategies.
|
||||
- **Workspace browser behavior is composition-owned** — grouping, ordering, search, and row state belong to [ui-workspace](../ui-workspace/README.md), not this shell.
|
||||
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
|
||||
侧边栏外壳插件:负责字标、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
|
||||
|
||||
New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。
|
||||
New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。
|
||||
|
||||
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。
|
||||
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。
|
||||
|
||||
栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。
|
||||
|
||||
@@ -25,5 +25,5 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:没有可用的 done/error 通知数据源。
|
||||
- **分组只支持 Workspace**:Update 和 Status 不是可用策略。
|
||||
- **Workspace 浏览行为由组合持有**:分组、排序、搜索与行状态都属于 [ui-workspace](../ui-workspace/README.md),不属于此外壳。
|
||||
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
gap: 6px;
|
||||
height: 38px;
|
||||
padding: 8px 16px;
|
||||
margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */
|
||||
margin: 0 2px 8px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
@@ -215,16 +215,20 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: -4px;
|
||||
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
|
||||
padding-left: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsed .regionArea {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Foot seat: a pure layout socket pinned under the region; the ui-settings
|
||||
trigger row inside owns its own geometry (49px wide row / 36px rail
|
||||
trigger row inside owns its own geometry (38px wide row / 36px rail
|
||||
circle) and hover chrome. */
|
||||
.footArea {
|
||||
flex: none;
|
||||
|
||||
@@ -58,8 +58,8 @@ export interface SidebarSettingsOwnerProps {
|
||||
export type SidebarRootInjected = {
|
||||
/**
|
||||
* Start a New Session: with a workspace, reuse-or-create its blank session
|
||||
* and open it; without one, clear the selection into the New Session pure
|
||||
* view state (the conversation.empty seat).
|
||||
* and open it; without one, inherit the current Session Workspace, then the
|
||||
* recent Workspace, or clear into the New Session pure view when none exist.
|
||||
*/
|
||||
startSession: (workspaceId?: WorkspaceId) => void
|
||||
/** Toggle the sidebar column through the layout service. */
|
||||
|
||||
@@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
const injectProps = (): SidebarRootInjected => ({
|
||||
// The shell's New Session button rides the runtime's shared action
|
||||
// (recent-Workspace targeting; explicit Workspace wins for scoped actions).
|
||||
// (current Session Workspace, then recent Workspace).
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
toggleSidebar: () => { ctx.layout.toggleSidebar() },
|
||||
})
|
||||
|
||||
@@ -30,9 +30,13 @@ describe('SidebarRoot.module.css inset', () => {
|
||||
const root = declarations('.root')
|
||||
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
|
||||
expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
|
||||
expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px')
|
||||
expect(declarations('.regionArea')?.get('padding-left')).toBe('4px')
|
||||
expect(declarations('.regionArea')?.get('margin-right')).toBe(
|
||||
'calc(-1 * var(--dsh-sidebar-inline-padding))',
|
||||
)
|
||||
expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0')
|
||||
expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0')
|
||||
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationViewRegistry, SlotsService,
|
||||
@@ -82,9 +83,11 @@ describe('tsdown client artifact', () => {
|
||||
// Paging is session-owned; this registration-only probe never renders the
|
||||
// entry, so the binding stays deliberately empty. The locale plugin backs
|
||||
// the locale-aware view tab label (its settings scope needs a connection
|
||||
// handle).
|
||||
// handle and the Host-facing settings/remote seams).
|
||||
ctx.provide('sessions', { binding: () => undefined })
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = await import('@deepseek-ai/dsh-client-locale/client')
|
||||
ctx.plugin({ inject: [...locale.inject], apply: locale.apply })
|
||||
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08
|
||||
README.zh.md: 8edd0fed6d3bdefd9df339a8b3d0588533264538
|
||||
README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc
|
||||
README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462
|
||||
|
||||
@@ -4,7 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow.
|
||||
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode.
|
||||
|
||||
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
|
||||
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。
|
||||
|
||||
|
||||
@@ -38,9 +38,8 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Section header: 36px, "Workspaces/Sessions" label + group-by /
|
||||
new-workspace buttons; the right-anchored new-workspace button is the
|
||||
row's rail survivor. */
|
||||
/* Section header: title, an inline search control, and the two trailing
|
||||
actions. Expanding search collapses the action cluster and takes its room. */
|
||||
.sectionHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -48,7 +47,7 @@
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
padding-left: 4px;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
@@ -56,71 +55,118 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.root:not(.rail) .sectionHeader {
|
||||
margin-top: 2px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: 1;
|
||||
flex: none;
|
||||
max-width: 45%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition:
|
||||
max-width 180ms var(--ds-ease-in-out),
|
||||
margin-right 180ms var(--ds-ease-in-out),
|
||||
opacity 120ms var(--ds-ease-in-out),
|
||||
transform 180ms var(--ds-ease-in-out),
|
||||
visibility 0s linear;
|
||||
}
|
||||
|
||||
/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off
|
||||
corners); rail state renders it as the
|
||||
region's search control. Upstream binds a dedicated design-system variable
|
||||
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
|
||||
token pinned to the static scale mirrors it. */
|
||||
.search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
|
||||
.sectionLabelHidden {
|
||||
max-width: 0;
|
||||
margin-right: -4px;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
visibility: hidden;
|
||||
transition-delay: 0s, 0s, 0s, 0s, 180ms;
|
||||
}
|
||||
|
||||
.searchSlot {
|
||||
flex: 1;
|
||||
max-width: 28px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
padding-left: 0;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
max-width 180ms var(--ds-ease-in-out),
|
||||
padding-left 180ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.searchSlotExpanded {
|
||||
max-width: 100%;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
margin: 0 2px 12px;
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
gap: 4px;
|
||||
max-width: 60px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
visibility: visible;
|
||||
transition:
|
||||
max-width 180ms var(--ds-ease-in-out),
|
||||
opacity 120ms var(--ds-ease-in-out),
|
||||
transform 180ms var(--ds-ease-in-out),
|
||||
visibility 0s linear;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
|
||||
.headerActionsHidden {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition-delay: 0s, 0s, 0s, 180ms;
|
||||
}
|
||||
|
||||
/* The capsule's leading icon: decorative while wide (pointer-events off so
|
||||
clicks reach the input), the hit target in rail state. */
|
||||
.searchButton {
|
||||
/* Inline search always fills the room between the title and trailing actions;
|
||||
it grows farther right when the action cluster collapses. */
|
||||
.search {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 180ms var(--ds-ease-in-out),
|
||||
padding 180ms var(--ds-ease-in-out),
|
||||
border-color 180ms var(--ds-ease-in-out),
|
||||
background-color 180ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
.searchExpanded {
|
||||
width: calc(100% + 4px);
|
||||
height: 30px;
|
||||
margin-inline: -2px;
|
||||
padding: 0 4px 0 0;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
.searchButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -132,9 +178,66 @@
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.searchExpanded .searchButton {
|
||||
width: 28px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchExpanded .searchButton:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
transition: opacity 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.searchExpanded .searchInput {
|
||||
margin-left: -2px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Rail variant (own .rail class from the wide owner prop — the region never
|
||||
reads the shell's class names): the two icon controls stack as 36x36
|
||||
circles matching the shell's rail rhythm. */
|
||||
@@ -144,6 +247,10 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rail .headerActions {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.rail .iconButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -151,6 +258,7 @@
|
||||
}
|
||||
|
||||
.rail .search {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
@@ -162,8 +270,6 @@
|
||||
.rail .searchButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -177,12 +283,18 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: -4px;
|
||||
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
|
||||
overflow: hidden;
|
||||
padding-left: 4px;
|
||||
/* The list remains the scroll clip. This seat stays visible so the
|
||||
absolutely positioned first-boundary marker can occupy the header gap. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rail .listArea {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Relative for the bottom fade overlay. */
|
||||
@@ -194,14 +306,14 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
|
||||
/* Bottom fade: compact overlay pinned to the visible bottom,
|
||||
transparent -> sidebar fill so it tracks the theme. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: var(--dsh-session-list-edge-inset);
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
height: 24px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -223,15 +335,17 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-left: -4px;
|
||||
margin-right: var(--dsh-session-list-scrollbar-offset);
|
||||
padding-left: 4px;
|
||||
padding-right: calc(
|
||||
var(--dsh-session-list-edge-inset)
|
||||
- var(--dsh-session-list-scrollbar-width)
|
||||
- var(--dsh-session-list-scrollbar-offset)
|
||||
);
|
||||
/* Clears the 72px bottom fade overlay: at scroll end the last row sits
|
||||
/* Clears the compact bottom fade overlay: at scroll end the last row sits
|
||||
above the gradient instead of under it. */
|
||||
padding-bottom: 48px;
|
||||
padding-bottom: 16px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@@ -254,10 +368,84 @@
|
||||
}
|
||||
|
||||
/* One workspace section: header row + a compact expanded session run. */
|
||||
.groupSection {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.groupSection + .groupSection {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.listTopDropIndicator,
|
||||
.workspaceDropBefore::before,
|
||||
.workspaceDropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
background:
|
||||
linear-gradient(
|
||||
55deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 0 / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
125deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 5px / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
var(--dsw-alias-state-business-primary) 0 0
|
||||
) 4px 5px / calc(100% - 4px) 2px no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The first insertion boundary keeps the same -8px coordinate as every
|
||||
Workspace boundary, but lives outside the scrolling clip. */
|
||||
.listTopDropIndicator {
|
||||
top: -8px;
|
||||
left: 0;
|
||||
right: var(--dsh-session-list-edge-inset);
|
||||
}
|
||||
|
||||
.listTopDropActive > .workspaceDropBefore:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspaceDropBefore::before {
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.workspaceDropAfter::after {
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.sessionOverflowButton {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px 0 28px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.groupSection > .sessionOverflowButton {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sessionOverflowButton:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
@@ -305,4 +493,12 @@
|
||||
.wide {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.search,
|
||||
.sectionLabel,
|
||||
.searchSlot,
|
||||
.searchInput,
|
||||
.headerActions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The workspace/session browsing region filling the sidebar shell's
|
||||
* `sidebar.workspaces` hole: section header (title + group-by + add
|
||||
* `sidebar.workspaces` hole: section header (title + view options + add
|
||||
* workspace), search, the grouped tree or flat list, and the workspace
|
||||
* dialogs. Wide state renders the full browser; rail state renders the two
|
||||
* region icons (search / add workspace), each requesting shell expansion
|
||||
@@ -16,12 +16,13 @@ import {
|
||||
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
||||
SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
||||
import type { SessionNode } from './tree.ts'
|
||||
import type { SessionNode, SessionOrderBy } from './tree.ts'
|
||||
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
|
||||
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
import { FLAT_SESSION_ORDER_KEY } from './stores.ts'
|
||||
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
|
||||
import css from './WorkspaceBrowser.module.css'
|
||||
|
||||
@@ -34,6 +35,8 @@ const EXPAND_SLIDE_MS = 300
|
||||
const SEARCH_DEBOUNCE_MS = 250
|
||||
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
|
||||
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
||||
/** Session rows visible per Workspace before the local overflow control. */
|
||||
const COLLAPSED_SESSION_LIMIT = 5
|
||||
|
||||
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
||||
function sanitizeSearchQuery(value: string): string {
|
||||
@@ -46,15 +49,106 @@ function sanitizeSearchQuery(value: string): string {
|
||||
return withoutNul.slice(0, end)
|
||||
}
|
||||
|
||||
/** Immutable membership toggle for the local expansion arrays. */
|
||||
/** Immutable membership toggle for the local expand-all array. */
|
||||
function toggled(list: readonly string[], key: string): string[] {
|
||||
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
||||
}
|
||||
|
||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||
function GroupByMenu({ groupBy, onPick, t }: {
|
||||
/**
|
||||
* Accept the native drag at document level while a row drag is active: row
|
||||
* hover still owns the insertion marker, and releasing outside the list must
|
||||
* not be rendered as a rejected drop before dragend commits that last marker.
|
||||
*/
|
||||
function useNativeDragAcceptance(active: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const acceptDrag = (event: DragEvent): void => {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
const acceptDrop = (event: DragEvent): void => { event.preventDefault() }
|
||||
document.addEventListener('dragover', acceptDrag)
|
||||
document.addEventListener('drop', acceptDrop)
|
||||
return () => {
|
||||
document.removeEventListener('dragover', acceptDrag)
|
||||
document.removeEventListener('drop', acceptDrop)
|
||||
}
|
||||
}, [active])
|
||||
}
|
||||
|
||||
/** Reconcile a stored view order with the Workspace's current session account. */
|
||||
function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] {
|
||||
if (stored === undefined) return [...sessionIds]
|
||||
const byId = new Map(sessionIds.map(id => [id as string, id]))
|
||||
const ordered: SessionId[] = []
|
||||
const included = new Set<string>()
|
||||
for (const key of stored) {
|
||||
const id = byId.get(key)
|
||||
if (id === undefined || included.has(key)) continue
|
||||
ordered.push(id)
|
||||
included.add(key)
|
||||
}
|
||||
for (const id of sessionIds) {
|
||||
if (included.has(id)) continue
|
||||
ordered.push(id)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
/** Newest update first with stable Session identity as the tie-break. */
|
||||
function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number {
|
||||
const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY
|
||||
const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY
|
||||
if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt
|
||||
return a < b ? -1 : 1
|
||||
}
|
||||
|
||||
/** Reconcile one editable order account and apply its activity-promotion policy. */
|
||||
function nextSessionOrderAccount({
|
||||
sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency,
|
||||
}: {
|
||||
sessionIds: readonly SessionId[]
|
||||
previousOrder: readonly string[] | undefined
|
||||
previousUpdatedAt: Readonly<Record<string, number>>
|
||||
list: SessionListState
|
||||
orderBy: SessionOrderBy
|
||||
sortByRecency: boolean
|
||||
}): { order: SessionId[]; updatedAt: Record<string, number>; changed: boolean } {
|
||||
let order = reconciledSessionOrder(sessionIds, previousOrder)
|
||||
if (sortByRecency) {
|
||||
order.sort((a, b) => compareSessionRecency(a, b, list.byId))
|
||||
} else if (orderBy === 'updated') {
|
||||
const promoted = sessionIds
|
||||
.filter((id) => {
|
||||
const session = list.byId[id]
|
||||
return session !== undefined
|
||||
&& (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id])
|
||||
})
|
||||
.sort((a, b) => compareSessionRecency(a, b, list.byId))
|
||||
if (promoted.length > 0) {
|
||||
const promotedIds = new Set(promoted)
|
||||
order = [...promoted, ...order.filter(id => !promotedIds.has(id))]
|
||||
}
|
||||
}
|
||||
const updatedAt: Record<string, number> = {}
|
||||
for (const id of sessionIds) {
|
||||
const session = list.byId[id]
|
||||
if (session !== undefined) updatedAt[id] = session.updatedAt
|
||||
}
|
||||
const orderChanged = previousOrder === undefined
|
||||
|| order.length !== previousOrder.length
|
||||
|| order.some((id, index) => id !== previousOrder[index])
|
||||
const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length
|
||||
|| Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp)
|
||||
return { order, updatedAt, changed: orderChanged || timestampsChanged }
|
||||
}
|
||||
|
||||
/** Grouping and ordering menu; own open state so it resets with the wide chrome. */
|
||||
function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
||||
groupBy: 'workspace' | 'flat'
|
||||
onPick: (mode: 'workspace' | 'flat') => void
|
||||
orderBy: SessionOrderBy
|
||||
onGroupPick: (mode: 'workspace' | 'flat') => void
|
||||
onOrderPick: (mode: SessionOrderBy) => void
|
||||
t: WorkspaceBrowserProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -66,23 +160,28 @@ function GroupByMenu({ groupBy, onPick, t }: {
|
||||
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
|
||||
{ id: 'workspace', label: t('groupBy.workspace') },
|
||||
{ id: 'flat', label: t('groupBy.flat') },
|
||||
{ type: 'separator' as const, id: 'order-by-separator' },
|
||||
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
|
||||
{ id: 'manual', label: t('orderBy.manual') },
|
||||
{ id: 'updated', label: t('orderBy.updated') },
|
||||
]}
|
||||
selectedId={groupBy}
|
||||
selectedIds={[groupBy, orderBy]}
|
||||
onSelect={(id) => {
|
||||
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
|
||||
if (id === 'workspace' || id === 'flat') onPick(id)
|
||||
if (id === 'workspace' || id === 'flat') onGroupPick(id)
|
||||
else if (id === 'manual' || id === 'updated') onOrderPick(id)
|
||||
setOpen(false)
|
||||
}}
|
||||
align="end"
|
||||
dense
|
||||
// Portal: the section header clips overflow, so an in-place list would
|
||||
// be cut off at the header's bounds.
|
||||
portal
|
||||
anchor={(
|
||||
<Tooltip label={t('groupBy.label')} side="bottom" delayMs={500}>
|
||||
<Tooltip label={t('viewOptions.label')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label={t('groupBy.label')}
|
||||
aria-label={t('viewOptions.label')}
|
||||
onClick={() => { setOpen(v => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
@@ -95,17 +194,43 @@ function GroupByMenu({ groupBy, onPick, t }: {
|
||||
|
||||
/** In-flight root-row drag: source identity plus the current insert marker. */
|
||||
interface DragState {
|
||||
workspaceId: WorkspaceId
|
||||
/** Workspace id, or {@link UNGROUPED_KEY} for the browser-local loose-session account. */
|
||||
workspaceKey: string
|
||||
sessionId: SessionNode['id']
|
||||
/** Row the marker sits on and which half (insert above/below it). */
|
||||
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
|
||||
}
|
||||
|
||||
/** In-flight Workspace-row drag: source identity plus the current marker. */
|
||||
interface WorkspaceDragState {
|
||||
workspaceId: WorkspaceId
|
||||
over: { id: WorkspaceId; half: 'before' | 'after' } | null
|
||||
}
|
||||
|
||||
/** Resolve an insertion side from the full rendered workspace group. */
|
||||
function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
type SessionTreeProps = Pick<
|
||||
WorkspaceBrowserProps,
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession'
|
||||
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Explicit persisted zero-or-five-session state by Workspace group. */
|
||||
workspaceExpansion: Readonly<Record<string, boolean>>
|
||||
/** Persist one Workspace group's zero-or-five-session state. */
|
||||
setWorkspaceExpanded: (key: string, expanded: boolean) => void
|
||||
/** Shared editable orders used by Workspace groups and the flat-list account. */
|
||||
recentSessionOrder: Readonly<Record<string, readonly string[]>>
|
||||
/** Last update timestamps observed for one-time recent-update promotions. */
|
||||
recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
|
||||
/** Replace one shared order and its observed timestamps. */
|
||||
syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record<string, number>) => void
|
||||
/** Apply a drag to one shared order. */
|
||||
setRecentSessionOrder: (workspaceKey: string, order: string[]) => void
|
||||
/** Registry-global archive set (hidden rows). */
|
||||
archivedSessionIds: readonly SessionNode['id'][]
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
@@ -116,127 +241,378 @@ type SessionTreeProps = Pick<
|
||||
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
||||
/** Archive a session (row menu action; the row disappears on the state echo). */
|
||||
onSessionArchive: (sessionId: SessionNode['id']) => void
|
||||
/** Session order behavior: fixed after edits, or additionally promoted by user activity. */
|
||||
orderBy: SessionOrderBy
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
|
||||
insertWorkspaceBefore, insertSessionBefore, orderBy,
|
||||
workspaceExpansion, setWorkspaceExpanded,
|
||||
recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
|
||||
// Transient drag marker state; the selected mode owns the resulting order.
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
const sessionDropCommitted = useRef(false)
|
||||
const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
|
||||
const workspaceDropCommitted = useRef(false)
|
||||
const previousOrderBy = useRef(orderBy)
|
||||
const nativeDragActive = drag !== null || workspaceDrag !== null
|
||||
useNativeDragAcceptance(nativeDragActive)
|
||||
const currentGroup = current === undefined
|
||||
? undefined
|
||||
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
if (current === undefined || currentGroup === undefined || Object.hasOwn(workspaceExpansion, currentGroup)) return
|
||||
setWorkspaceExpanded(currentGroup, true)
|
||||
}, [current, currentGroup, setWorkspaceExpanded, workspaceExpansion])
|
||||
const expandedProjects = useMemo(
|
||||
() => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
|
||||
[workspaceExpansion],
|
||||
)
|
||||
const ungroupedSessionIds = useMemo(() => {
|
||||
const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
|
||||
return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
|
||||
}, [list, workspaces])
|
||||
useEffect(() => {
|
||||
if (list.phase !== 'ready') return
|
||||
const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
|
||||
previousOrderBy.current = orderBy
|
||||
const accounts = [
|
||||
...workspaces.map(workspace => ({
|
||||
key: workspace.workspaceId as string,
|
||||
sessionIds: workspace.sessionIds.filter(id => list.byId[id] !== undefined),
|
||||
})),
|
||||
{ key: UNGROUPED_KEY, sessionIds: ungroupedSessionIds },
|
||||
]
|
||||
for (const { key, sessionIds } of accounts) {
|
||||
const previousOrder = recentSessionOrder[key]
|
||||
const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
|
||||
const next = nextSessionOrderAccount({
|
||||
sessionIds,
|
||||
previousOrder,
|
||||
previousUpdatedAt,
|
||||
list,
|
||||
orderBy,
|
||||
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
|
||||
})
|
||||
if (next.changed) {
|
||||
syncRecentSessions(key, next.order.map(id => id as string), next.updatedAt)
|
||||
}
|
||||
}
|
||||
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, ungroupedSessionIds, workspaces])
|
||||
const orderedWorkspaces = useMemo(() => {
|
||||
return workspaces.map((workspace) => {
|
||||
const stored = recentSessionOrder[workspace.workspaceId as string]
|
||||
const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored)
|
||||
return { ...workspace, sessionIds }
|
||||
})
|
||||
}, [recentSessionOrder, workspaces])
|
||||
const orderedUngroupedSessionIds = useMemo(
|
||||
() => reconciledSessionOrder(ungroupedSessionIds, recentSessionOrder[UNGROUPED_KEY]),
|
||||
[recentSessionOrder, ungroupedSessionIds],
|
||||
)
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
|
||||
[list, workspaces, archivedSessionIds, expandedProjects],
|
||||
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
|
||||
expandedProjects,
|
||||
...(recentSessionOrder[UNGROUPED_KEY] === undefined
|
||||
? {}
|
||||
: { ungroupedOrder: recentSessionOrder[UNGROUPED_KEY] }),
|
||||
}),
|
||||
[list, orderedWorkspaces, archivedSessionIds, expandedProjects, recentSessionOrder],
|
||||
)
|
||||
const now = Date.now()
|
||||
const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
|
||||
if (sessionDropCommitted.current) return
|
||||
sessionDropCommitted.current = true
|
||||
setDrag(null)
|
||||
const group = groups.find(candidate => candidate.key === activeDrag.workspaceKey)
|
||||
if (group === undefined) return
|
||||
const targetIndex = group.sessions.findIndex(session => session.id === over.id)
|
||||
if (targetIndex === -1) return
|
||||
const anchor = over.half === 'before' ? over.id : group.sessions[targetIndex + 1]?.id
|
||||
if (anchor === activeDrag.sessionId) return
|
||||
const sourceIndex = group.sessions.findIndex(session => session.id === activeDrag.sessionId)
|
||||
const anchorIndex = anchor === undefined
|
||||
? group.sessions.length
|
||||
: group.sessions.findIndex(session => session.id === anchor)
|
||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||
const accountSessionIds = activeDrag.workspaceKey === UNGROUPED_KEY
|
||||
? orderedUngroupedSessionIds
|
||||
: orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceKey)?.sessionIds
|
||||
if (accountSessionIds === undefined) return
|
||||
const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId)
|
||||
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
||||
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
|
||||
setRecentSessionOrder(activeDrag.workspaceKey, nextOrder.map(id => id as string))
|
||||
if (orderBy === 'updated' || activeDrag.workspaceKey === UNGROUPED_KEY) return
|
||||
insertSessionBefore(activeDrag.workspaceKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
|
||||
console.warn('session reorder rejected:', reason)
|
||||
})
|
||||
}
|
||||
const commitWorkspaceDrag = (
|
||||
activeDrag: WorkspaceDragState,
|
||||
over: NonNullable<WorkspaceDragState['over']>,
|
||||
): void => {
|
||||
if (workspaceDropCommitted.current) return
|
||||
workspaceDropCommitted.current = true
|
||||
setWorkspaceDrag(null)
|
||||
const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === over.id)
|
||||
if (rowIndex === -1) return
|
||||
const anchor = over.half === 'before' ? over.id : workspaces[rowIndex + 1]?.workspaceId
|
||||
if (anchor === activeDrag.workspaceId) return
|
||||
const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId)
|
||||
const anchorIndex = anchor === undefined
|
||||
? workspaces.length
|
||||
: workspaces.findIndex(workspace => workspace.workspaceId === anchor)
|
||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||
insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason: unknown) => {
|
||||
console.warn('workspace reorder rejected:', reason)
|
||||
})
|
||||
}
|
||||
const workspaceDropAtListStart = groups[0]?.workspaceId !== undefined
|
||||
&& workspaceDrag?.over?.id === groups[0].workspaceId
|
||||
&& workspaceDrag.over.half === 'before'
|
||||
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
|
||||
{workspaceDropAtListStart && <span className={css.listTopDropIndicator} aria-hidden="true" />}
|
||||
<div
|
||||
className={clsx(css.list, workspaceDropAtListStart && css.listTopDropActive)}
|
||||
role="tree"
|
||||
aria-label={t('section.sessions')}
|
||||
>
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{t('empty.none')}</div>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
{groups.map((group) => {
|
||||
const workspaceId = group.workspaceId
|
||||
const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId
|
||||
? workspaceDrag.over.half
|
||||
: null
|
||||
const workspaceDragProps = workspaceId === undefined ? undefined : {
|
||||
start: () => {
|
||||
workspaceDropCommitted.current = false
|
||||
setWorkspaceDrag({ workspaceId, over: null })
|
||||
},
|
||||
end: () => {
|
||||
if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) {
|
||||
commitWorkspaceDrag(workspaceDrag, workspaceDrag.over)
|
||||
} else {
|
||||
setWorkspaceDrag(null)
|
||||
}
|
||||
workspaceDropCommitted.current = false
|
||||
},
|
||||
}
|
||||
const hoverWorkspace = workspaceId === undefined
|
||||
? undefined
|
||||
: (half: 'before' | 'after') => {
|
||||
setWorkspaceDrag(active => active === null
|
||||
? active
|
||||
: { ...active, over: { id: workspaceId, half } })
|
||||
}
|
||||
const dropWorkspace = workspaceId === undefined
|
||||
? undefined
|
||||
: (half: 'before' | 'after') => {
|
||||
if (workspaceDrag === null) return
|
||||
commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half })
|
||||
}
|
||||
return (
|
||||
// Group section: header row + expanded top-level session rows. The
|
||||
// inter-group breathing room is the section's own margin
|
||||
// (WorkspaceBrowser.module.css).
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
t={t}
|
||||
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
actions={group.workspaceId === undefined
|
||||
<div
|
||||
key={group.key}
|
||||
className={clsx(
|
||||
css.groupSection,
|
||||
workspaceMarker === 'before' && css.workspaceDropBefore,
|
||||
workspaceMarker === 'after' && css.workspaceDropAfter,
|
||||
)}
|
||||
onDragOver={workspaceDrag === null || hoverWorkspace === undefined
|
||||
? undefined
|
||||
: {
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
: (e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
hoverWorkspace(workspaceGroupHalf(e))
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace session rows. The drag
|
||||
// never leaves its group — rows of other groups show no markers
|
||||
// and reject drops (visual movement confined to this section).
|
||||
const draggable = group.workspaceId !== undefined
|
||||
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
|
||||
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
|
||||
start: () => {
|
||||
setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
|
||||
},
|
||||
active: sameGroupDrag,
|
||||
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
|
||||
hover: (half: 'before' | 'after') => {
|
||||
onDrop={workspaceDrag === null || dropWorkspace === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.preventDefault()
|
||||
dropWorkspace(workspaceGroupHalf(e))
|
||||
}}
|
||||
>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
t={t}
|
||||
onToggle={() => {
|
||||
if (group.expanded) {
|
||||
setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
|
||||
}
|
||||
setWorkspaceExpanded(group.key, !group.expanded)
|
||||
}}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) {
|
||||
setWorkspaceExpanded(group.key, true)
|
||||
startSession(group.workspaceId)
|
||||
}
|
||||
}}
|
||||
drag={workspaceDragProps}
|
||||
actions={group.workspaceId === undefined
|
||||
? undefined
|
||||
: {
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{(expandedSessionGroups.includes(group.key)
|
||||
? group.sessions
|
||||
: group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
|
||||
).map((node) => {
|
||||
// Session drag never leaves its group. Ungrouped writes only the
|
||||
// browser-local account; real Workspaces may also write Host order.
|
||||
const sameGroupDrag = drag !== null && drag.workspaceKey === group.key
|
||||
const dragProps = {
|
||||
start: () => {
|
||||
sessionDropCommitted.current = false
|
||||
setDrag({ workspaceKey: group.key, sessionId: node.id, over: null })
|
||||
},
|
||||
active: sameGroupDrag,
|
||||
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
|
||||
hover: (half: 'before' | 'after') => {
|
||||
/* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
|
||||
setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
|
||||
},
|
||||
drop: (half: 'before' | 'after') => {
|
||||
setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
|
||||
},
|
||||
drop: (half: 'before' | 'after') => {
|
||||
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
|
||||
if (drag === null) return
|
||||
const sessions = group.sessions
|
||||
// Anchor = the row the insert line points at ('after' means
|
||||
// the next root; end-of-list omits the anchor → append).
|
||||
const anchor = half === 'before' ? node.id : sessions[index + 1]?.id
|
||||
setDrag(null)
|
||||
if (anchor === drag.sessionId) return
|
||||
// No-op when the drop lands back on the source position.
|
||||
const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId)
|
||||
const anchorIndex = anchor === undefined ? sessions.length : sessions.findIndex(r => r.id === anchor)
|
||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||
insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
|
||||
console.warn('session reorder rejected:', reason)
|
||||
})
|
||||
},
|
||||
end: () => { setDrag(null) },
|
||||
}
|
||||
return (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
drag={dragProps}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
if (drag === null) return
|
||||
commitSessionDrag(drag, { id: node.id, half })
|
||||
},
|
||||
end: () => {
|
||||
if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over)
|
||||
else setDrag(null)
|
||||
sessionDropCommitted.current = false
|
||||
},
|
||||
}
|
||||
return (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
drag={dragProps}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{group.sessions.length > COLLAPSED_SESSION_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.sessionOverflowButton}
|
||||
aria-expanded={expandedSessionGroups.includes(group.key)}
|
||||
onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }}
|
||||
>
|
||||
{expandedSessionGroups.includes(group.key)
|
||||
? t('sessions.collapse')
|
||||
: t('sessions.expand', { n: group.sessions.length - COLLAPSED_SESSION_LIMIT })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
|
||||
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
|
||||
/** The flat "In one list" body: every session is one draggable top-level row. */
|
||||
function FlatList({
|
||||
useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
|
||||
orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
|
||||
}: Pick<
|
||||
SessionTreeProps,
|
||||
| 'useSessions'
|
||||
| 'open'
|
||||
| 'forkSession'
|
||||
| 'onSessionRename'
|
||||
| 'onSessionArchive'
|
||||
| 'archivedSessionIds'
|
||||
| 'orderBy'
|
||||
| 'recentSessionOrder'
|
||||
| 'recentSessionUpdatedAt'
|
||||
| 'syncRecentSessions'
|
||||
| 'setRecentSessionOrder'
|
||||
| 't'
|
||||
>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
|
||||
const baseRows = useMemo(
|
||||
() => deriveFlat(list, archivedSessionIds),
|
||||
[list, archivedSessionIds],
|
||||
)
|
||||
const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows])
|
||||
const previousOrderBy = useRef(orderBy)
|
||||
useEffect(() => {
|
||||
if (list.phase !== 'ready') return
|
||||
const previousOrder = recentSessionOrder[FLAT_SESSION_ORDER_KEY]
|
||||
const previousUpdatedAt = recentSessionUpdatedAt[FLAT_SESSION_ORDER_KEY] ?? {}
|
||||
const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
|
||||
previousOrderBy.current = orderBy
|
||||
const next = nextSessionOrderAccount({
|
||||
sessionIds,
|
||||
previousOrder,
|
||||
previousUpdatedAt,
|
||||
list,
|
||||
orderBy,
|
||||
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
|
||||
})
|
||||
if (next.changed) {
|
||||
syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt)
|
||||
}
|
||||
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions])
|
||||
const rows = useMemo(() => {
|
||||
const byId = new Map(baseRows.map(row => [row.id, row]))
|
||||
return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY])
|
||||
.flatMap((id) => {
|
||||
const row = byId.get(id)
|
||||
return row === undefined ? [] : [row]
|
||||
})
|
||||
}, [baseRows, recentSessionOrder, sessionIds])
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
const dropCommitted = useRef(false)
|
||||
useNativeDragAcceptance(drag !== null)
|
||||
const commitDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
|
||||
if (dropCommitted.current) return
|
||||
dropCommitted.current = true
|
||||
setDrag(null)
|
||||
const targetIndex = rows.findIndex(row => row.id === over.id)
|
||||
if (targetIndex === -1) return
|
||||
const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id
|
||||
if (anchor === activeDrag.sessionId) return
|
||||
const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId)
|
||||
const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor)
|
||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||
const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId)
|
||||
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
||||
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
|
||||
setRecentSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string))
|
||||
}
|
||||
const now = Date.now()
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
@@ -244,19 +620,42 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
|
||||
{rows.length === 0 && (
|
||||
<div className={css.empty}>{t('empty.none')}</div>
|
||||
)}
|
||||
{rows.map(node => (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
currentId={list.current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{rows.map((node) => {
|
||||
const active = drag !== null
|
||||
return (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
currentId={list.current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
flat
|
||||
drag={{
|
||||
start: () => {
|
||||
dropCommitted.current = false
|
||||
setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null })
|
||||
},
|
||||
active,
|
||||
marker: active && drag.over?.id === node.id ? drag.over.half : null,
|
||||
hover: (half) => {
|
||||
setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } })
|
||||
},
|
||||
drop: (half) => {
|
||||
if (drag !== null) commitDrag(drag, { id: node.id, half })
|
||||
},
|
||||
end: () => {
|
||||
if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over)
|
||||
else setDrag(null)
|
||||
dropCommitted.current = false
|
||||
},
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
@@ -352,6 +751,7 @@ export function WorkspaceBrowser({
|
||||
forkSession,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
insertWorkspaceBefore,
|
||||
archiveSession,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
@@ -362,14 +762,28 @@ export function WorkspaceBrowser({
|
||||
t,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const workspacePhase = useWorkspaces(state => state.phase)
|
||||
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
|
||||
// Live occupancy of this surface's directory-flow hole (the same source the
|
||||
// flow reads): a composition without a picking affordance can add nothing.
|
||||
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
const orderBy = useStore(s => s.orderBy)
|
||||
const workspaceExpansion = useStore(s => s.workspaceExpansion)
|
||||
const recentSessionOrder = useStore(s => s.recentSessionOrder)
|
||||
const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt)
|
||||
useEffect(() => {
|
||||
if (workspacePhase !== 'ready') return
|
||||
actions.retainWorkspaceKeys([
|
||||
UNGROUPED_KEY,
|
||||
FLAT_SESSION_ORDER_KEY,
|
||||
...workspaces.map(workspace => workspace.workspaceId as string),
|
||||
])
|
||||
}, [actions.retainWorkspaceKeys, workspacePhase, workspaces])
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchExpanded, setSearchExpanded] = useState(false)
|
||||
const normalizedQuery = sanitizeSearchQuery(query).trim()
|
||||
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
|
||||
query: '',
|
||||
@@ -377,6 +791,7 @@ export function WorkspaceBrowser({
|
||||
items: [],
|
||||
hasMore: false,
|
||||
})
|
||||
const searchRoot = useRef<HTMLDivElement | null>(null)
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
// Section-header + opens the picker menu (same popover in wide and rail
|
||||
// states; the menu anchors on this button).
|
||||
@@ -397,6 +812,23 @@ export function WorkspaceBrowser({
|
||||
}
|
||||
}, [wide, searchOnExpand])
|
||||
|
||||
useEffect(() => {
|
||||
if (!wide || !searchExpanded || searchOnExpand) return
|
||||
searchInput.current?.focus({ preventScroll: true })
|
||||
}, [wide, searchExpanded, searchOnExpand])
|
||||
|
||||
useEffect(() => {
|
||||
if (!wide || !searchExpanded) return
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
|
||||
searchInput.current?.blur()
|
||||
if (normalizedQuery !== '') return
|
||||
setSearchExpanded(false)
|
||||
}
|
||||
document.addEventListener('click', onClick)
|
||||
return () => { document.removeEventListener('click', onClick) }
|
||||
}, [normalizedQuery, wide, searchExpanded])
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedQuery === '') {
|
||||
setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false })
|
||||
@@ -544,29 +976,96 @@ export function WorkspaceBrowser({
|
||||
<div className={clsx(css.root, !wide && css.rail)}>
|
||||
<div className={css.sectionHeader}>
|
||||
{wide && (
|
||||
<span className={clsx(css.sectionLabel, css.wide)}>
|
||||
<span className={clsx(css.sectionLabel, css.wide, searchExpanded && css.sectionLabelHidden)}>
|
||||
{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
|
||||
</span>
|
||||
)}
|
||||
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
|
||||
{/* Adding is the button's one action, so a composition with no
|
||||
picking affordance has nothing to offer here: the region hides the
|
||||
button rather than leaving a dead one in the header. */}
|
||||
{directoryFlowAvailable && (
|
||||
<Tooltip label={t('workspace.add')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
ref={wsPlusRef}
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('workspace.add')}
|
||||
{wide && (
|
||||
<div className={clsx(css.searchSlot, searchExpanded && css.searchSlotExpanded)}>
|
||||
<div
|
||||
ref={searchRoot}
|
||||
className={clsx(css.search, searchExpanded && css.searchExpanded)}
|
||||
onClick={() => {
|
||||
setWsPickerOpen(v => !v)
|
||||
setWsPickerOpen(false)
|
||||
setSearchExpanded(true)
|
||||
searchInput.current?.focus()
|
||||
}}
|
||||
>
|
||||
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('search')} side="bottom" delayMs={500} disabled={searchExpanded}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label={t('search.sessions.aria')}
|
||||
aria-expanded={searchExpanded}
|
||||
onClick={() => {
|
||||
setWsPickerOpen(false)
|
||||
setSearchExpanded(true)
|
||||
}}
|
||||
>
|
||||
<IconSearchOutline16 size={searchExpanded ? 11 : 14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={searchInput}
|
||||
className={css.searchInput}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
|
||||
value={query}
|
||||
tabIndex={searchExpanded ? 0 : -1}
|
||||
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
setQuery('')
|
||||
setSearchExpanded(false)
|
||||
}}
|
||||
/>
|
||||
{searchExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.clearButton}
|
||||
aria-label={t('search.clear')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setQuery('')
|
||||
setSearchExpanded(false)
|
||||
}}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={clsx(css.headerActions, wide && searchExpanded && css.headerActionsHidden)}>
|
||||
{wide && (
|
||||
<ViewOptionsMenu
|
||||
groupBy={groupBy}
|
||||
orderBy={orderBy}
|
||||
onGroupPick={(mode) => { actions.setGroupBy(mode) }}
|
||||
onOrderPick={(mode) => { actions.setOrderBy(mode) }}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{/* Adding is the button's one action, so a composition with no
|
||||
picking affordance has nothing to offer here: the region hides the
|
||||
button rather than leaving a dead one in the header. */}
|
||||
{directoryFlowAvailable && (
|
||||
<Tooltip label={t('workspace.add')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
ref={wsPlusRef}
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('workspace.add')}
|
||||
onClick={() => {
|
||||
setWsPickerOpen(v => !v)
|
||||
}}
|
||||
>
|
||||
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/* Add flow + its error dialog (same package — direct composition). */}
|
||||
<WorkspacePickFlow
|
||||
t={t}
|
||||
@@ -586,42 +1085,23 @@ export function WorkspaceBrowser({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
decorative). Rail: the icon is the region's search control. */}
|
||||
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
|
||||
<Tooltip label={t('search')} disabled={wide}>
|
||||
{/* The collapsed rail keeps search as its own 36px control. */}
|
||||
{!wide && <div className={css.search}>
|
||||
<Tooltip label={t('search')}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label={t('search.sessions.aria')}
|
||||
tabIndex={wide ? -1 : 0}
|
||||
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
|
||||
onClick={() => {
|
||||
setSearchExpanded(true)
|
||||
setSearchOnExpand(true)
|
||||
expandSidebar()
|
||||
}}
|
||||
>
|
||||
<IconSearchOutline16 size={wide ? 14 : 18} />
|
||||
<IconSearchOutline16 size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{wide && (
|
||||
<input
|
||||
ref={searchInput}
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
|
||||
/>
|
||||
)}
|
||||
{wide && query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.clearButton, css.wide)}
|
||||
aria-label={t('search.clear')}
|
||||
onClick={() => { setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Always-mounted seat keeps the region's flex slot while the list
|
||||
itself is wide-only. */}
|
||||
@@ -644,7 +1124,13 @@ export function WorkspaceBrowser({
|
||||
<FlatList
|
||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
|
||||
archivedSessionIds={archivedSessionIds} t={t}
|
||||
archivedSessionIds={archivedSessionIds}
|
||||
orderBy={orderBy}
|
||||
recentSessionOrder={recentSessionOrder}
|
||||
recentSessionUpdatedAt={recentSessionUpdatedAt}
|
||||
syncRecentSessions={actions.syncRecentSessions}
|
||||
setRecentSessionOrder={actions.setRecentSessionOrder}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
@@ -654,10 +1140,18 @@ export function WorkspaceBrowser({
|
||||
onSessionArchive={onSessionArchive}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
workspaceExpansion={workspaceExpansion}
|
||||
setWorkspaceExpanded={actions.setWorkspaceExpanded}
|
||||
recentSessionOrder={recentSessionOrder}
|
||||
recentSessionUpdatedAt={recentSessionUpdatedAt}
|
||||
syncRecentSessions={actions.syncRecentSessions}
|
||||
setRecentSessionOrder={actions.setRecentSessionOrder}
|
||||
archivedSessionIds={archivedSessionIds}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
insertWorkspaceBefore={insertWorkspaceBefore}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
orderBy={orderBy}
|
||||
t={t}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
|
||||
@@ -91,9 +91,9 @@ export type DirectoryPickingHooks = {
|
||||
*/
|
||||
export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
/**
|
||||
* Start a New Session in a Workspace: reuse-or-create its blank session
|
||||
* and open it; with no workspace, clear the selection into the New Session
|
||||
* pure view state (the conversation.empty seat).
|
||||
* Start a New Session in a Workspace: reuse-or-create its blank session and
|
||||
* open it; without an explicit workspace, inherit the current Session
|
||||
* Workspace, then the recent Workspace, or clear into the New Session view.
|
||||
*/
|
||||
startSession: (workspaceId?: WorkspaceId) => void
|
||||
/** Open a real Session. */
|
||||
@@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Reorder a Workspace in the durable registry display order.
|
||||
* Omitted anchor appends to the end.
|
||||
*/
|
||||
insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Archive a Session into the registry-global set: hidden from grouping
|
||||
* surfaces, log and accounting slot retained. Archiving the current
|
||||
|
||||
@@ -68,8 +68,8 @@ export function apply(ctx: ClientContext): void {
|
||||
const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow')
|
||||
const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow')
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
// Explicit group actions keep their target; unscoped New Session rides
|
||||
// the runtime's shared action (recent-Workspace projection inside).
|
||||
// Explicit group actions keep their target; unscoped New Session inherits
|
||||
// the current Session Workspace before the recent-Workspace fallback.
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
searchSessions,
|
||||
@@ -91,6 +91,9 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => {
|
||||
await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId)
|
||||
},
|
||||
archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
|
||||
@@ -10,14 +10,20 @@ export const zh = {
|
||||
'session.new': '新会话',
|
||||
'section.workspaces': '工作区',
|
||||
'section.sessions': '会话',
|
||||
'viewOptions.label': '视图选项',
|
||||
'groupBy.label': '分组方式',
|
||||
'groupBy.workspace': '按工作区',
|
||||
'groupBy.flat': '单列表',
|
||||
'orderBy.label': '排序方式',
|
||||
'orderBy.manual': '手动排序',
|
||||
'orderBy.updated': '最近更新',
|
||||
'sessions.expand': '展开其余 {n} 个会话',
|
||||
'sessions.collapse': '收起',
|
||||
'empty.none': '暂无会话',
|
||||
'empty.noMatches': '无匹配结果',
|
||||
'workspace.add': '添加工作区',
|
||||
'search.sessions.aria': '搜索会话',
|
||||
'search.placeholder': '搜索名称、关键词…',
|
||||
'search.placeholder': '搜索会话…',
|
||||
'search.clear': '清除搜索',
|
||||
'search.results.aria': '搜索结果',
|
||||
'search.pending': '正在搜索会话历史…',
|
||||
@@ -73,14 +79,20 @@ export const en = {
|
||||
'session.new': 'New Session',
|
||||
'section.workspaces': 'Workspaces',
|
||||
'section.sessions': 'Sessions',
|
||||
'viewOptions.label': 'View options',
|
||||
'groupBy.label': 'Group by',
|
||||
'groupBy.workspace': 'WorkSpace',
|
||||
'groupBy.flat': 'In one list',
|
||||
'orderBy.label': 'Order by',
|
||||
'orderBy.manual': 'Manual',
|
||||
'orderBy.updated': 'Last updated',
|
||||
'sessions.expand': 'Show {n} more sessions',
|
||||
'sessions.collapse': 'Show less',
|
||||
'empty.none': 'No sessions yet',
|
||||
'empty.noMatches': 'No matches',
|
||||
'workspace.add': 'Add workspace',
|
||||
'search.sessions.aria': 'Search sessions',
|
||||
'search.placeholder': 'Search name, keywords...',
|
||||
'search.placeholder': 'Search sessions...',
|
||||
'search.clear': 'Clear search',
|
||||
'search.results.aria': 'Search results',
|
||||
'search.pending': 'Searching session history…',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px
|
||||
single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps
|
||||
/* Tree rows: project 34px, session 32px, radius 8, indent step 22px
|
||||
(16px slot + 6px gap). Hover swaps
|
||||
are pure CSS: project folder -> chevron + action buttons; session time ->
|
||||
ellipsis button. */
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
.sessionRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchResultRow {
|
||||
@@ -29,11 +29,11 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 7px 8px;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
@@ -45,7 +45,7 @@
|
||||
}
|
||||
|
||||
.searchResultRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchResultHeading {
|
||||
@@ -64,9 +64,16 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.searchResultMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.searchResultWorkspace,
|
||||
.searchResultSnippet {
|
||||
margin-left: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -75,21 +82,21 @@
|
||||
}
|
||||
|
||||
.searchResultWorkspace {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.searchResultSnippet {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Two-line row: the leading slot (folder/chevron), title, and trailing
|
||||
actions all top-align on the 20px first text line (figma cell) — content
|
||||
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
|
||||
/* Compact one-line Workspace row after removing the session-count subtitle. */
|
||||
.projectRow {
|
||||
height: 54px;
|
||||
align-items: flex-start;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -99,7 +106,7 @@
|
||||
|
||||
/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
|
||||
.sessionRow {
|
||||
height: 34px;
|
||||
height: 32px;
|
||||
gap: 0;
|
||||
/* Mount fade: session rows appear by unfolding a group (or the tree
|
||||
mounting). Stable row keys keep already-visible rows from replaying it. */
|
||||
@@ -110,6 +117,10 @@
|
||||
margin: 0 6px 0 4px;
|
||||
}
|
||||
|
||||
.flatSessionRowWithoutStatus .title {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
@@ -133,11 +144,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.folderActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
|
||||
/* Project leading slot: folder by default, expand arrow on row hover. */
|
||||
.projectRow .chevron { display: none; }
|
||||
.projectRow:hover .chevron { display: inline-flex; }
|
||||
@@ -233,14 +244,46 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Drag reorder insert line (workspace-group session rows): 2px accent above or
|
||||
below the hovered row, drawn with box-shadow so no layout shift. */
|
||||
.sessionRow.dropBefore {
|
||||
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
|
||||
/* Session drag insert marker: a leading chevron and 2px rule between rows,
|
||||
absolutely positioned so it neither resembles a row border nor changes layout. */
|
||||
.sessionRow.dropBefore,
|
||||
.sessionRow.dropAfter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sessionRow.dropAfter {
|
||||
box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary);
|
||||
.sessionRow.dropBefore::before,
|
||||
.sessionRow.dropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
right: 4px;
|
||||
height: 12px;
|
||||
background:
|
||||
linear-gradient(
|
||||
55deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 0 / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
125deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 5px / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
var(--dsw-alias-state-business-primary) 0 0
|
||||
) 4px 5px / calc(100% - 4px) 2px no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sessionRow.dropBefore::before {
|
||||
top: -7px;
|
||||
}
|
||||
|
||||
.sessionRow.dropAfter::after {
|
||||
bottom: -7px;
|
||||
}
|
||||
|
||||
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
|
||||
|
||||
@@ -67,29 +67,60 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Project (workspace) header row: 54px, folder + title + session count;
|
||||
* Row drag wiring supplied by the tree owner. `drop` reports the half of the
|
||||
* row where the pointer released so the owner can resolve an insert anchor.
|
||||
*/
|
||||
export interface RowDragProps {
|
||||
/** Start dragging this row. */
|
||||
start: () => void
|
||||
/** A compatible row drag is in flight. */
|
||||
active: boolean
|
||||
/** Current marker on this row: insert line above, below, or none. */
|
||||
marker: 'before' | 'after' | null
|
||||
/** Report the hovered half while a compatible drag passes over this row. */
|
||||
hover: (half: 'before' | 'after') => void
|
||||
drop: (half: 'before' | 'after') => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */
|
||||
interface WorkspaceRowDragProps {
|
||||
start: () => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/** Pointer-position half of a row (insert line above or below). */
|
||||
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
/**
|
||||
* Project (workspace) header row: folder + title;
|
||||
* hover reveals the chevron and create button, and dwelling on a real
|
||||
* Workspace shows its hover card (the ungrouped bucket has none).
|
||||
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
|
||||
* @param props.group - derived group node.
|
||||
* @param props.onToggle - expand/collapse the group.
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
* @param props.drag - optional workspace-row drag wiring.
|
||||
* @param props.t - the browser root's locale seat.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
|
||||
export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: {
|
||||
group: GroupNode
|
||||
onToggle: () => void
|
||||
onCreate: () => void
|
||||
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
|
||||
actions?: { rename: () => void; delete: () => void } | undefined
|
||||
/** Present only for real Workspace rows in the grouped view. */
|
||||
drag?: WorkspaceRowDragProps | undefined
|
||||
t: RowTranslate
|
||||
}) {
|
||||
const row = group
|
||||
// The ungrouped bucket has no workspace title: its label is dictionary copy.
|
||||
const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
|
||||
const active = group.expanded && group.containsCurrent
|
||||
const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount })
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const workspaceMenuItems = [
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
|
||||
@@ -101,6 +132,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
|
||||
role="treeitem"
|
||||
aria-expanded={row.expanded}
|
||||
onClick={onToggle}
|
||||
draggable={drag !== undefined}
|
||||
onDragStart={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', row.key)
|
||||
drag.start()
|
||||
}}
|
||||
onDragEnd={drag?.end}
|
||||
>
|
||||
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
||||
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
||||
@@ -110,7 +150,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
|
||||
</span>
|
||||
<span className={css.projectText}>
|
||||
<span className={css.title}>{label}</span>
|
||||
<span className={css.meta}>{count}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
{actions !== undefined && (
|
||||
@@ -220,6 +259,18 @@ function sessionStatuses(
|
||||
return [{ state: 'done', label: t('status.idle') }]
|
||||
}
|
||||
|
||||
/** Primary status dot plus every status's screen-reader label, shared by the search and session rows. */
|
||||
function SessionStatusDots({ statuses }: { statuses: readonly [SessionStatus, ...SessionStatus[]] }) {
|
||||
return (
|
||||
<>
|
||||
<StateDot state={statuses[0].state} />
|
||||
{statuses.map(status => (
|
||||
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Hover-card body: full title, relative time, and every relevant live status. */
|
||||
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
|
||||
const statuses = sessionStatuses(node, t)
|
||||
@@ -239,24 +290,6 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number;
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-row drag wiring supplied by the group owner (workspace groups only).
|
||||
* `drop` reports the half of the row the pointer released on: 'before'
|
||||
* inserts above this row, 'after' below it (the owner resolves the anchor).
|
||||
*/
|
||||
export interface RowDragProps {
|
||||
/** Start dragging this row. */
|
||||
start: () => void
|
||||
/** A drag from the same group is in flight (rows show insert markers). */
|
||||
active: boolean
|
||||
/** Current marker on this row: insert line above, below, or none. */
|
||||
marker: 'before' | 'after' | null
|
||||
/** Report the hovered half while a same-group drag passes over this row. */
|
||||
hover: (half: 'before' | 'after') => void
|
||||
drop: (half: 'before' | 'after') => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One flat search result: title, Workspace context, and optional content
|
||||
* excerpt. Search navigation opens the session only; it does not address an
|
||||
@@ -287,30 +320,21 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
|
||||
<span className={css.searchResultHeading}>
|
||||
<span className={css.slot}>
|
||||
{(primaryStatus.state !== 'done' || result.completed) && (
|
||||
<>
|
||||
<StateDot state={primaryStatus.state} />
|
||||
{statuses.map(status => (
|
||||
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
|
||||
))}
|
||||
</>
|
||||
<SessionStatusDots statuses={statuses} />
|
||||
)}
|
||||
</span>
|
||||
<span className={css.searchResultTitle}>{result.title}</span>
|
||||
</span>
|
||||
<span className={css.searchResultWorkspace}>{result.workspace}</span>
|
||||
{result.snippet !== undefined && (
|
||||
<span className={css.searchResultSnippet}>{result.snippet}</span>
|
||||
)}
|
||||
<span className={css.searchResultMeta}>
|
||||
<span className={css.searchResultWorkspace}>{result.workspace}</span>
|
||||
{result.snippet !== undefined && (
|
||||
<span className={css.searchResultSnippet}>{result.snippet}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pointer-position half of a row (insert line above or below). */
|
||||
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
/**
|
||||
* One top-level 34px session row: status dot (pending user interaction outranks
|
||||
* own or descendant activity), title, relative time, and the row actions menu.
|
||||
@@ -322,10 +346,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
* @param props.onFork - fork a session at its last completed turn.
|
||||
* @param props.onArchive - archive a session by id.
|
||||
* @param props.drag - optional draggable-row wiring.
|
||||
* @param props.flat - omit the empty status slot in the hierarchy-free flat list.
|
||||
* @param props.t - the browser root's locale seat.
|
||||
* @returns the session row.
|
||||
*/
|
||||
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
|
||||
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: {
|
||||
node: SessionNode
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
@@ -338,6 +363,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
onArchive: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group sessions outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
/** The row is rendered without a parent Workspace header. */
|
||||
flat?: boolean | undefined
|
||||
t: RowTranslate
|
||||
}) {
|
||||
const row = node
|
||||
@@ -345,6 +372,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
const selected = node.id === currentId
|
||||
const statuses = sessionStatuses(node, t)
|
||||
const primaryStatus = statuses[0]
|
||||
const showStatus = primaryStatus.state !== 'done' || row.completed
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Archive hides the row through the registry-global archive set and never
|
||||
// touches the session log, so it is not styled as destructive and needs no
|
||||
@@ -360,6 +388,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
<div
|
||||
className={clsx(
|
||||
css.sessionRow, selected && css.selected, menuOpen && css.menuOpen,
|
||||
flat && !showStatus && css.flatSessionRowWithoutStatus,
|
||||
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
|
||||
)}
|
||||
role="treeitem"
|
||||
@@ -370,6 +399,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', node.id)
|
||||
drag.start()
|
||||
}}
|
||||
onDragEnd={drag?.end}
|
||||
@@ -392,16 +422,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
{/* Pending interaction and own or descendant activity outrank the
|
||||
finished-but-unviewed reminder, which returns after activity stops
|
||||
and is cleared by opening the session. */}
|
||||
<span className={css.slot}>
|
||||
{(primaryStatus.state !== 'done' || row.completed) && (
|
||||
<>
|
||||
<StateDot state={primaryStatus.state} />
|
||||
{statuses.map(status => (
|
||||
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{(!flat || showStatus) && (
|
||||
<span className={css.slot}>
|
||||
{showStatus && <SessionStatusDots statuses={statuses} />}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.title}>{title}</span>
|
||||
{/* A blank New Session row is a provisional placeholder: nothing has
|
||||
happened in it yet, so a "now" timestamp and the row verbs
|
||||
|
||||
@@ -7,11 +7,25 @@
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Browser-local order account for the hierarchy-free flat Session list. */
|
||||
export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'
|
||||
|
||||
/** Session-list grouping mode: workspace sections or one flat recency list. */
|
||||
export type WorkspaceGroupBy = 'workspace' | 'flat'
|
||||
/** Session order: user-arranged only, or user-arranged plus activity promotion. */
|
||||
export type WorkspaceOrderBy = 'manual' | 'updated'
|
||||
|
||||
/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */
|
||||
type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
|
||||
/** Workspace browser viewing state persisted across surface remounts and reloads. */
|
||||
type WorkspaceViewState = {
|
||||
groupBy: WorkspaceGroupBy
|
||||
orderBy: WorkspaceOrderBy
|
||||
/** Explicit zero-or-five-session state keyed by Workspace group identity. */
|
||||
workspaceExpansion: Record<string, boolean>
|
||||
/** Shared editable order per Workspace group plus the browser-local flat-list account. */
|
||||
recentSessionOrder: Record<string, string[]>
|
||||
/** Last observed update timestamps per order account for one-time promotion events. */
|
||||
recentSessionUpdatedAt: Record<string, Record<string, number>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
@@ -19,6 +33,16 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
|
||||
*/
|
||||
type WorkspaceViewActions = {
|
||||
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
||||
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
|
||||
setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
|
||||
retainWorkspaceKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void
|
||||
syncRecentSessions: (
|
||||
draft: WorkspaceViewState,
|
||||
workspaceKey: string,
|
||||
order: string[],
|
||||
updatedAt: Record<string, number>,
|
||||
) => void
|
||||
setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,10 +51,37 @@ type WorkspaceViewActions = {
|
||||
*/
|
||||
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
|
||||
return defineStore({
|
||||
init: (): WorkspaceViewState => ({ groupBy: 'workspace' }),
|
||||
persist: 'dsh.workspace.view',
|
||||
init: (): WorkspaceViewState => ({
|
||||
groupBy: 'workspace',
|
||||
orderBy: 'manual',
|
||||
workspaceExpansion: {},
|
||||
recentSessionOrder: {},
|
||||
recentSessionUpdatedAt: {},
|
||||
}),
|
||||
persist: 'dsh.workspace.view.v4',
|
||||
actions: {
|
||||
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
||||
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
|
||||
setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded },
|
||||
retainWorkspaceKeys: (d, workspaceKeys: readonly string[]) => {
|
||||
const retained = new Set(workspaceKeys)
|
||||
d.workspaceExpansion = Object.fromEntries(
|
||||
Object.entries(d.workspaceExpansion).filter(([key]) => retained.has(key)),
|
||||
)
|
||||
d.recentSessionOrder = Object.fromEntries(
|
||||
Object.entries(d.recentSessionOrder).filter(([key]) => retained.has(key)),
|
||||
)
|
||||
d.recentSessionUpdatedAt = Object.fromEntries(
|
||||
Object.entries(d.recentSessionUpdatedAt).filter(([key]) => retained.has(key)),
|
||||
)
|
||||
},
|
||||
syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record<string, number>) => {
|
||||
d.recentSessionOrder[workspaceKey] = order
|
||||
d.recentSessionUpdatedAt[workspaceKey] = updatedAt
|
||||
},
|
||||
setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => {
|
||||
d.recentSessionOrder[workspaceKey] = order
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface SessionNode {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Session order selected by the Workspace browser. */
|
||||
export type SessionOrderBy = 'manual' | 'updated'
|
||||
|
||||
/** One workspace group section: header row facts + visible top-level session rows. */
|
||||
export interface GroupNode {
|
||||
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
||||
@@ -75,6 +78,8 @@ export interface SearchResultSet {
|
||||
/** Viewing state consumed by the derivation. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
/** Browser-local order for Sessions without a backing Workspace account. */
|
||||
ungroupedOrder?: readonly string[]
|
||||
}
|
||||
|
||||
interface Group {
|
||||
@@ -136,21 +141,41 @@ function buildGroup(
|
||||
order: 'account' | 'recency',
|
||||
): Group {
|
||||
const sessions = [...members]
|
||||
// Workspace order is workspace.sessionIds; only Ungrouped lacks an account
|
||||
// order and therefore falls back to recency.
|
||||
// Real Workspace order comes from sessionIds. Ungrouped falls back to
|
||||
// recency until the browser supplies its persisted local order.
|
||||
if (order === 'recency') sessions.sort(byRecency)
|
||||
return { key, workspaceId, cwd, createdAt, label, sessions }
|
||||
}
|
||||
|
||||
/** Apply a stored Ungrouped order and append newly loose Sessions by recency. */
|
||||
function orderedUngrouped(members: readonly SessionSummary[], stored: readonly string[]): SessionSummary[] {
|
||||
const byId = new Map(members.map(session => [session.id as string, session]))
|
||||
const included = new Set<string>()
|
||||
const ordered: SessionSummary[] = []
|
||||
for (const key of stored) {
|
||||
const session = byId.get(key)
|
||||
if (session === undefined || included.has(key)) continue
|
||||
ordered.push(session)
|
||||
included.add(key)
|
||||
}
|
||||
for (const session of [...members].sort(byRecency)) {
|
||||
if (included.has(session.id)) continue
|
||||
ordered.push(session)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Sessions by Host Workspace: one group per entity in stable Host
|
||||
* order, with members resolved from sessionIds in their stored order. Sessions
|
||||
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
|
||||
* outside every Workspace trail in the browser-local Ungrouped order, which
|
||||
* falls back to recency before that order is initialized.
|
||||
*/
|
||||
function groupByWorkspace(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
archived: ReadonlySet<SessionId>,
|
||||
ungroupedOrder: readonly string[] | undefined,
|
||||
): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
@@ -173,7 +198,15 @@ function groupByWorkspace(
|
||||
.filter((s): s is SessionSummary =>
|
||||
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
groups.push(buildGroup(
|
||||
UNGROUPED_KEY,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
UNGROUPED_LABEL,
|
||||
ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder),
|
||||
ungroupedOrder === undefined ? 'recency' : 'account',
|
||||
))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -197,8 +230,8 @@ function sessionNode(
|
||||
/**
|
||||
* Derive the workspace browser groups with every session as a top-level row.
|
||||
*
|
||||
* Every group shows; sessions populate under expanded groups, preserving
|
||||
* Host account order. Blank sessions are excluded except for the selected
|
||||
* Every group shows; sessions populate under expanded groups in the selected
|
||||
* local order. Blank sessions are excluded except for the selected
|
||||
* provisional New Session row; archived sessions are excluded everywhere.
|
||||
* Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
@@ -222,7 +255,7 @@ export function deriveGroups(
|
||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces, archived)) {
|
||||
for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
@@ -248,7 +281,10 @@ export function deriveGroups(
|
||||
* @param archivedSessionIds - registry-global archive set.
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
|
||||
export function deriveFlat(
|
||||
list: SessionListState,
|
||||
archivedSessionIds: readonly SessionId[],
|
||||
): SessionNode[] {
|
||||
const archived = new Set(archivedSessionIds)
|
||||
const descendants = indexSubagentDescendants(list.byId)
|
||||
const rows: SessionSummary[] = []
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
|
||||
const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
/**
|
||||
* Declarations of one selector rule, keyed by property with whitespace collapsed.
|
||||
@@ -15,21 +16,23 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.m
|
||||
* @param selector - one exact selector, including a leading dot for local classes.
|
||||
* @returns the rule's declarations, or undefined when no such rule exists.
|
||||
*/
|
||||
function declarations(selector: string): Map<string, string> | undefined {
|
||||
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
function declarationsFrom(source: string, selector: string): Map<string, string> | undefined {
|
||||
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const found = new Map<string, string>()
|
||||
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
|
||||
const found = new Map<string, string>()
|
||||
for (const part of body.split(';')) {
|
||||
const colon = part.indexOf(':')
|
||||
if (colon === -1) continue
|
||||
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
|
||||
}
|
||||
return found
|
||||
}
|
||||
return undefined
|
||||
return found.size === 0 ? undefined : found
|
||||
}
|
||||
|
||||
const declarations = (selector: string): Map<string, string> | undefined => declarationsFrom(css, selector)
|
||||
const rowDeclarations = (selector: string): Map<string, string> | undefined => declarationsFrom(rowsCss, selector)
|
||||
|
||||
describe('WorkspaceBrowser.module.css list', () => {
|
||||
const root = declarations('.root')
|
||||
const listArea = declarations('.listArea')
|
||||
@@ -45,9 +48,13 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
|
||||
expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px')
|
||||
expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
|
||||
expect(listArea?.get('margin-left')).toBe('-4px')
|
||||
expect(listArea?.get('padding-left')).toBe('4px')
|
||||
expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))')
|
||||
expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)')
|
||||
expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)')
|
||||
expect(list?.get('margin-left')).toBe('-4px')
|
||||
expect(list?.get('padding-left')).toBe('4px')
|
||||
expect(list?.get('padding-right')).toBe([
|
||||
'calc(',
|
||||
'var(--dsh-session-list-edge-inset)',
|
||||
@@ -68,4 +75,36 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
|
||||
expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
|
||||
})
|
||||
|
||||
it('draws drag targets as a leading chevron joined to the insertion line', () => {
|
||||
const listTopMarker = declarations('.listTopDropIndicator')
|
||||
const workspaceMarker = declarations('.workspaceDropBefore::before')
|
||||
const sessionMarker = rowDeclarations('.sessionRow.dropBefore::before')
|
||||
expect(listTopMarker?.get('top')).toBe('-8px')
|
||||
expect(listTopMarker?.get('left')).toBe('0')
|
||||
expect(workspaceMarker?.get('left')).toBe('0')
|
||||
expect(sessionMarker?.get('left')).toBe('0')
|
||||
for (const marker of [listTopMarker, workspaceMarker, sessionMarker]) {
|
||||
expect(marker?.get('height')).toBe('12px')
|
||||
expect(marker?.get('background')).not.toContain('radial-gradient')
|
||||
expect(marker?.get('background')).toContain('55deg')
|
||||
expect(marker?.get('background')).toContain('125deg')
|
||||
expect(marker?.get('background')).toContain('calc(50% - 1px) calc(50% + 1px)')
|
||||
expect(marker?.get('background')).toContain('0 0 / 5px 7px')
|
||||
expect(marker?.get('background')).toContain('0 5px / 5px 7px')
|
||||
expect(marker?.get('background')).toContain('4px 5px / calc(100% - 4px) 2px')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the compact fade, overflow control, search field, and row heights', () => {
|
||||
expect(declarations('.fade')?.get('height')).toBe('24px')
|
||||
expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px')
|
||||
expect(declarations('.searchExpanded')?.get('height')).toBe('30px')
|
||||
expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px')
|
||||
expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px')
|
||||
expect(rowDeclarations('.flatSessionRowWithoutStatus .title')?.get('margin-left')).toBe('0')
|
||||
expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px')
|
||||
expect(rowDeclarations('.sessionRow.selected')?.get('background'))
|
||||
.toBe('var(--dsw-alias-interactive-bg-hover)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ function installClipboard(writeText: (text: string) => Promise<void>): () => voi
|
||||
}
|
||||
}
|
||||
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() }
|
||||
|
||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||
@@ -57,6 +57,21 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
|
||||
}
|
||||
|
||||
describe('workspace browser rows', () => {
|
||||
it('omits only an empty leading status slot in the hierarchy-free flat list', () => {
|
||||
const idle: SessionNode = {
|
||||
id: sid('flat'), title: 'Flat Session', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
}
|
||||
const view = render(<SessionNodeItem node={idle} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
|
||||
const title = screen.getByText('Flat Session')
|
||||
expect(title.previousElementSibling).toBeNull()
|
||||
|
||||
view.rerender(<SessionNodeItem node={{ ...idle, running: true }} currentId={undefined} now={0}
|
||||
onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
|
||||
expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a selected content-search row and opens only its session', () => {
|
||||
const onOpen = vi.fn()
|
||||
const result: SearchResultNode = {
|
||||
@@ -105,7 +120,6 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
|
||||
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -11,7 +11,8 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
id: sid(id), displayTitle: id, running: false, blank: false,
|
||||
updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
@@ -23,8 +24,9 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const view = (expandedProjects: readonly string[] = []) => ({
|
||||
const view = (expandedProjects: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({
|
||||
expandedProjects,
|
||||
...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
|
||||
})
|
||||
const noArchive: readonly SessionId[] = []
|
||||
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
|
||||
@@ -53,6 +55,19 @@ describe('deriveGroups', () => {
|
||||
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
|
||||
})
|
||||
|
||||
it('applies stored Ungrouped order and appends new loose Sessions by recency', () => {
|
||||
const sessions = list(summary('one', 3), summary('two', 2), summary('new', 4))
|
||||
const groups = deriveGroups(
|
||||
sessions,
|
||||
[],
|
||||
noArchive,
|
||||
view([UNGROUPED_KEY], ['two', 'stale', 'two']),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([
|
||||
sid('two'), sid('new'), sid('one'),
|
||||
])
|
||||
})
|
||||
|
||||
it('shows only the current blank session in its Workspace count and tree', () => {
|
||||
const currentBlank = { ...summary('current-blank', 5), blank: true }
|
||||
const staleBlank = { ...summary('stale-blank', 4), blank: true }
|
||||
@@ -377,11 +392,38 @@ describe('deriveSearchResults', () => {
|
||||
})
|
||||
|
||||
describe('createWorkspaceViewStore', () => {
|
||||
it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
|
||||
it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
expect(store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(store.getSnapshot().orderBy).toBe('manual')
|
||||
store.actions.setGroupBy('flat')
|
||||
store.actions.setOrderBy('updated')
|
||||
store.actions.setWorkspaceExpanded('alpha', true)
|
||||
store.actions.syncRecentSessions('alpha', ['two', 'one'], { one: 1, two: 2 })
|
||||
store.actions.setRecentSessionOrder('alpha', ['one', 'two'])
|
||||
expect(store.getSnapshot().groupBy).toBe('flat')
|
||||
expect(store.getSnapshot()).toMatchObject({
|
||||
orderBy: 'updated',
|
||||
workspaceExpansion: { alpha: true },
|
||||
recentSessionOrder: { alpha: ['one', 'two'] },
|
||||
recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('removes view state outside the retained Workspace key set', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
store.actions.setWorkspaceExpanded('', true)
|
||||
store.actions.setWorkspaceExpanded('alpha', true)
|
||||
store.actions.setWorkspaceExpanded('deleted', true)
|
||||
store.actions.syncRecentSessions('alpha', ['alpha-session'], { 'alpha-session': 2 })
|
||||
store.actions.syncRecentSessions('deleted', ['deleted-session'], { 'deleted-session': 1 })
|
||||
|
||||
store.actions.retainWorkspaceKeys(['', 'alpha'])
|
||||
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.workspaceExpansion).toEqual({ '': true, alpha: true })
|
||||
expect(snapshot.recentSessionOrder).toEqual({ alpha: ['alpha-session'] })
|
||||
expect(snapshot.recentSessionUpdatedAt).toEqual({ alpha: { 'alpha-session': 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
|
||||
import { UNGROUPED_KEY } from '../src/client/tree.ts'
|
||||
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
@@ -53,6 +54,10 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
|
||||
fireEvent(row, event)
|
||||
}
|
||||
|
||||
function dragData(): Pick<DataTransfer, 'effectAllowed' | 'dropEffect' | 'setData'> {
|
||||
return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() }
|
||||
}
|
||||
|
||||
function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
const props: WorkspaceBrowserProps = {
|
||||
@@ -71,6 +76,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
archiveSession: vi.fn(async () => {}),
|
||||
insertWorkspaceBefore: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
@@ -89,6 +95,28 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser', () => {
|
||||
it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => {
|
||||
const pending = {
|
||||
...workspaceState([]),
|
||||
phase: 'pending' as const,
|
||||
state: 'loading' as const,
|
||||
baselinesReady: false,
|
||||
}
|
||||
const b = mount({ useWorkspaces: hook(pending) })
|
||||
act(() => {
|
||||
b.store.actions.setWorkspaceExpanded('deleted', true)
|
||||
b.store.actions.syncRecentSessions('deleted', ['session'], { session: 1 })
|
||||
})
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ deleted: true })
|
||||
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([])) })
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({})
|
||||
expect(b.store.getSnapshot().recentSessionOrder).toEqual({ [UNGROUPED_KEY]: [] })
|
||||
expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({ [UNGROUPED_KEY]: {} })
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the grouped tree by default and switches to the flat list via Group by', () => {
|
||||
const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
|
||||
const b = mount({
|
||||
@@ -100,8 +128,14 @@ describe('WorkspaceBrowser', () => {
|
||||
// Sessions hidden while their group is folded.
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
|
||||
expect(screen.getByRole('separator')).toBeTruthy()
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
|
||||
'按工作区', '单列表', '手动排序', '最近更新',
|
||||
])
|
||||
expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy()
|
||||
expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
|
||||
expect(b.store.getSnapshot().groupBy).toBe('flat')
|
||||
@@ -111,18 +145,73 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.getByText('beta-s')).toBeTruthy()
|
||||
|
||||
// Back to workspace grouping through the same menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
expect(screen.getByRole('menuitem', { name: '手动排序' }).hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' }))
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(screen.getByText('工作区')).toBeTruthy()
|
||||
|
||||
// Escape closes the menu without picking.
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
})
|
||||
|
||||
it('persists flat-list drag order locally and applies Last updated within that account', async () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
||||
const workspaces = workspaceState([
|
||||
workspace('alpha', ['one']),
|
||||
workspace('beta', ['two']),
|
||||
])
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaces),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
|
||||
.toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
|
||||
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
||||
const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34,
|
||||
x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
fireDrag(three, 'drop', 180)
|
||||
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
|
||||
.toEqual(['two', 'three', 'one'])
|
||||
expect(insertSessionBefore).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
|
||||
.toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
fireDrag(three, 'drop', 180)
|
||||
b.view.unmount()
|
||||
|
||||
const restored = mount({ useSessions: hook(sessions), useWorkspaces: hook(workspaces) })
|
||||
expect(restored.store.getSnapshot().groupBy).toBe('flat')
|
||||
expect(restored.store.getSnapshot().orderBy).toBe('manual')
|
||||
expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([
|
||||
expect.stringContaining('two'),
|
||||
expect.stringContaining('three'),
|
||||
expect.stringContaining('one'),
|
||||
])
|
||||
})
|
||||
|
||||
it('expands a group on click and opens a session row', () => {
|
||||
const open = vi.fn()
|
||||
mount({
|
||||
@@ -138,6 +227,93 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows five sessions by default and clears transient show-all when the Workspace collapses', () => {
|
||||
const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index))
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState(items)),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
for (const item of items.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy()
|
||||
expect(screen.queryByText('session-6')).toBeNull()
|
||||
expect(screen.queryByText('session-7')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' }))
|
||||
expect(screen.getByText('session-6')).toBeTruthy()
|
||||
expect(screen.getByText('session-7')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: false })
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true })
|
||||
expect(screen.queryByText('session-6')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shares one editable order across modes and promotes only while Last updated is active', async () => {
|
||||
const initial = sessionState([summary('one', 3), summary('two', 2)])
|
||||
const b = mount({
|
||||
useSessions: hook(initial),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByRole('treeitem').slice(1)
|
||||
expect(rows[0]?.textContent).toContain('one')
|
||||
expect(rows[1]?.textContent).toContain('two')
|
||||
})
|
||||
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
fireDrag(two, 'drop', 180)
|
||||
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
|
||||
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
||||
|
||||
// User activity updates the timestamp baseline in Manual mode without
|
||||
// changing the shared visual order.
|
||||
const updated = sessionState([summary('one', 4), summary('two', 2)])
|
||||
rerender(b, { useSessions: hook(updated) })
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().recentSessionUpdatedAt.alpha).toEqual({ one: 4, two: 2 })
|
||||
})
|
||||
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
|
||||
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
||||
|
||||
// Entering Last updated performs one complete recency sort.
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two'])
|
||||
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
|
||||
})
|
||||
|
||||
// A later user activity timestamp promotes that Session once while the
|
||||
// mode remains active.
|
||||
const promoted = sessionState([summary('one', 4), summary('two', 5)])
|
||||
rerender(b, { useSessions: hook(promoted) })
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
|
||||
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
||||
})
|
||||
|
||||
b.view.unmount()
|
||||
const restored = mount({
|
||||
useSessions: hook(promoted),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
|
||||
})
|
||||
expect(restored.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
|
||||
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
||||
})
|
||||
|
||||
it('archives a session from the row menu and hides archived rows in both modes', async () => {
|
||||
const archiveSession = vi.fn(async () => {})
|
||||
const b = mount({
|
||||
@@ -150,11 +326,10 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
|
||||
expect(archiveSession).toHaveBeenCalledWith(sid('gone-s'))
|
||||
|
||||
// The archive-set echo hides the row in grouped mode (count included) and flat mode.
|
||||
// The archive-set echo hides the row in grouped and flat modes.
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) })
|
||||
expect(screen.queryByText('gone-s')).toBeNull()
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
expect(screen.getByText('kept-s')).toBeTruthy()
|
||||
expect(screen.queryByText('gone-s')).toBeNull()
|
||||
@@ -195,16 +370,20 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
it('auto-expands the selected session group and starts a session from the group +', () => {
|
||||
it('expands the target group before starting a session from its +', () => {
|
||||
const startSession = vi.fn()
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })),
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
|
||||
startSession,
|
||||
})
|
||||
// The current-group effect expanded the owning group without a click.
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
startSession.mockImplementation(() => {
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true })
|
||||
})
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true })
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
})
|
||||
|
||||
@@ -253,7 +432,6 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.getByText('新会话')).toBeTruthy()
|
||||
expect(screen.queryByText('alpha-blank')).toBeNull()
|
||||
expect(screen.queryByText('beta-blank')).toBeNull()
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
|
||||
rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
|
||||
expect(screen.getAllByText('新会话')).toHaveLength(1)
|
||||
@@ -262,9 +440,9 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.getAllByText('新会话')).toHaveLength(1)
|
||||
// Search excludes blank rows entirely — neither the canonical stored
|
||||
// title nor the localized display label participates in matching.
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } })
|
||||
expect(screen.queryByText('新会话')).toBeNull()
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } })
|
||||
expect(screen.queryByText('新会话')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -279,7 +457,8 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
const resultTree = screen.getByRole('tree', { name: '搜索结果' })
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
@@ -302,6 +481,27 @@ describe('WorkspaceBrowser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('collapses an empty search on outside click but keeps a non-empty query expanded', () => {
|
||||
mount()
|
||||
const search = screen.getByRole('button', { name: '搜索会话' })
|
||||
fireEvent.click(search)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(document.body)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(search)
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.click(document.body)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(search)
|
||||
fireEvent.change(input, { target: { value: 'kept' } })
|
||||
fireEvent.click(document.body)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(input.value).toBe('kept')
|
||||
})
|
||||
|
||||
it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -320,7 +520,7 @@ describe('WorkspaceBrowser', () => {
|
||||
open,
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
|
||||
fireEvent.change(input, { target: { value: 'waterfall token' } })
|
||||
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
|
||||
expect(screen.queryByText('Research notes')).toBeNull()
|
||||
@@ -345,7 +545,7 @@ describe('WorkspaceBrowser', () => {
|
||||
try {
|
||||
const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
|
||||
mount({ searchSessions })
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
|
||||
expect(input.maxLength).toBe(500)
|
||||
fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
|
||||
expect(input.value).toBe('y'.repeat(500))
|
||||
@@ -376,7 +576,7 @@ describe('WorkspaceBrowser', () => {
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
|
||||
searchSessions,
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), {
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), {
|
||||
target: { value: 'needle' },
|
||||
})
|
||||
expect(screen.getByText('Needle title')).toBeTruthy()
|
||||
@@ -413,7 +613,7 @@ describe('WorkspaceBrowser', () => {
|
||||
])),
|
||||
searchSessions,
|
||||
})
|
||||
const input = screen.getByPlaceholderText('搜索名称、关键词…')
|
||||
const input = screen.getByPlaceholderText('搜索会话…')
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
|
||||
@@ -447,7 +647,7 @@ describe('WorkspaceBrowser', () => {
|
||||
? first
|
||||
: Promise.resolve({ items: [], hasMore: false }))
|
||||
mount({ searchSessions })
|
||||
const input = screen.getByPlaceholderText('搜索名称、关键词…')
|
||||
const input = screen.getByPlaceholderText('搜索会话…')
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
|
||||
@@ -470,7 +670,7 @@ describe('WorkspaceBrowser', () => {
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
|
||||
expect(screen.getByText('无匹配会话')).toBeTruthy()
|
||||
@@ -486,12 +686,12 @@ describe('WorkspaceBrowser', () => {
|
||||
const b = mount({ wide: false, expandSidebar })
|
||||
// No wide chrome in rail state.
|
||||
expect(screen.queryByText('工作区')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
// The wide flip mounts the input and focuses it after the slide.
|
||||
rerender(b, { wide: true })
|
||||
const input = screen.getByPlaceholderText('搜索名称、关键词…')
|
||||
const input = screen.getByPlaceholderText('搜索会话…')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
// Wide search button is decorative (tabIndex -1, no expand call).
|
||||
@@ -524,6 +724,84 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.getByText('alpha')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses the full expanded Workspace section when resolving a Workspace drop half', () => {
|
||||
const insertWorkspaceBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState(Array.from({ length: 5 }, (_, index) => summary(`beta-${index}`, index)))
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('alpha', []),
|
||||
workspace('beta', sessions.ids),
|
||||
workspace('tail', []),
|
||||
])),
|
||||
insertWorkspaceBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('beta'))
|
||||
const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement
|
||||
let targetSection = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement
|
||||
while (targetSection.parentElement?.getAttribute('role') !== 'tree') {
|
||||
targetSection = targetSection.parentElement as HTMLElement
|
||||
}
|
||||
targetSection.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 300, left: 0, right: 200, width: 200, height: 200, x: 0, y: 100, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(source, { dataTransfer: dragData() })
|
||||
// y=190 is below the header row but still in the top half of the whole
|
||||
// expanded section, so the target is before beta rather than after it.
|
||||
fireDrag(targetSection, 'drop', 190)
|
||||
expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
|
||||
})
|
||||
|
||||
it('draws the first Workspace insertion boundary on the scroll container', () => {
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('alpha', []),
|
||||
workspace('beta', []),
|
||||
])),
|
||||
})
|
||||
const source = screen.getByText('beta').closest('[role="treeitem"]') as HTMLElement
|
||||
let firstSection = screen.getByText('alpha').closest('[role="treeitem"]')?.parentElement as HTMLElement
|
||||
while (firstSection.parentElement?.getAttribute('role') !== 'tree') {
|
||||
firstSection = firstSection.parentElement as HTMLElement
|
||||
}
|
||||
firstSection.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(source, { dataTransfer: dragData() })
|
||||
fireDrag(firstSection, 'dragOver', 105)
|
||||
expect(firstSection.parentElement?.className).toContain('listTopDropActive')
|
||||
const marker = firstSection.parentElement?.previousElementSibling
|
||||
expect(marker?.className).toContain('listTopDropIndicator')
|
||||
})
|
||||
|
||||
it('accepts a document-level drop and commits the last Workspace marker on drag end', () => {
|
||||
const insertWorkspaceBefore = vi.fn(async () => {})
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('alpha', []),
|
||||
workspace('beta', []),
|
||||
workspace('tail', []),
|
||||
])),
|
||||
insertWorkspaceBefore,
|
||||
})
|
||||
const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement
|
||||
let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement
|
||||
while (target.parentElement?.getAttribute('role') !== 'tree') {
|
||||
target = target.parentElement as HTMLElement
|
||||
}
|
||||
target.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(source, { dataTransfer: dragData() })
|
||||
fireDrag(target, 'dragOver', 105)
|
||||
const outsideDrop = createEvent.drop(document.body)
|
||||
Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() })
|
||||
fireEvent(document.body, outsideDrop)
|
||||
expect(outsideDrop.defaultPrevented).toBe(true)
|
||||
fireEvent.dragEnd(source)
|
||||
expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
|
||||
})
|
||||
|
||||
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
||||
@@ -538,7 +816,7 @@ describe('WorkspaceBrowser', () => {
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = dragData()
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
// Drop on the top half of "three": insert one before three.
|
||||
fireDrag(three, 'dragOver', 205)
|
||||
@@ -559,6 +837,55 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('未分组'))
|
||||
|
||||
const dragAfter = (sourceTitle: string, targetTitle: string): void => {
|
||||
const source = screen.getByText(sourceTitle).closest('[role="treeitem"]') as HTMLElement
|
||||
const target = screen.getByText(targetTitle).closest('[role="treeitem"]') as HTMLElement
|
||||
target.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(source, { dataTransfer: dragData() })
|
||||
fireDrag(target, 'drop', 180)
|
||||
}
|
||||
|
||||
dragAfter('one', 'three')
|
||||
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
|
||||
dragAfter('two', 'one')
|
||||
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
|
||||
expect(insertSessionBefore).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
dragAfter('one', 'three')
|
||||
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
|
||||
expect(insertSessionBefore).not.toHaveBeenCalled()
|
||||
|
||||
b.view.unmount()
|
||||
const restored = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
expect(restored.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
|
||||
expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
|
||||
expect.stringContaining('two'),
|
||||
expect.stringContaining('three'),
|
||||
expect.stringContaining('one'),
|
||||
])
|
||||
})
|
||||
|
||||
it('still sends the reorder when the dragged row left the group mid-drag', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
||||
@@ -569,7 +896,7 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
||||
fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } })
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
// The host dropped "one" from the workspace account while the drag is in
|
||||
// flight: the source index is gone but the drop still resolves its anchor.
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) })
|
||||
@@ -594,7 +921,7 @@ describe('WorkspaceBrowser', () => {
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = dragData()
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireEvent.dragEnd(one)
|
||||
// The drag ended: rows no longer accept drops.
|
||||
@@ -608,6 +935,28 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
|
||||
})
|
||||
|
||||
it('accepts a document-level drop and commits the last Session marker on drag end', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
fireDrag(two, 'dragOver', 180)
|
||||
const outsideDrop = createEvent.drop(document.body)
|
||||
Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() })
|
||||
fireEvent(document.body, outsideDrop)
|
||||
expect(outsideDrop.defaultPrevented).toBe(true)
|
||||
fireEvent.dragEnd(one)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
|
||||
})
|
||||
|
||||
it('logs and keeps the order when the reorder call rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
@@ -623,7 +972,7 @@ describe('WorkspaceBrowser', () => {
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = dragData()
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'drop', 180)
|
||||
await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
|
||||
@@ -778,7 +1127,7 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } })
|
||||
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
|
||||
expect(row.hasAttribute('draggable')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 541ebdb7a6802286b9b698575136534486387a8c
|
||||
README.zh.md: 94efce64edaf9983cc27285f7a61dd3252573c6e
|
||||
README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5
|
||||
README.zh.md: 54fcccc3fef46e717aaf05e3a7ace732a0f4b74a
|
||||
|
||||
@@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history.
|
||||
|
||||
Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
||||
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
|
||||
WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import {
|
||||
@@ -2758,6 +2758,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return ok(request, { deleted: true as const })
|
||||
},
|
||||
|
||||
async insertBefore(request) {
|
||||
const { workspaceId, beforeWorkspaceId } = request.payload
|
||||
try {
|
||||
const workspaceIds = await ctx.workspace.insertBefore(
|
||||
brandWorkspaceId(workspaceId),
|
||||
beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId),
|
||||
)
|
||||
return ok(request, { workspaceIds: [...workspaceIds] })
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof WorkspaceOrderInvalidError)) throw error
|
||||
return workspaceNotFound(request, error.workspaceId)
|
||||
}
|
||||
},
|
||||
|
||||
async insertSessionBefore(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
@@ -3412,9 +3426,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
host(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<HostFrame>>()
|
||||
const committedWorkspaces = ctx.workspace.list()
|
||||
const committedWorkspaceIds = new Set(
|
||||
ctx.workspace.list().map(workspace => String(workspace.id)),
|
||||
committedWorkspaces.map(workspace => String(workspace.id)),
|
||||
)
|
||||
let committedWorkspaceOrder = committedWorkspaces.map(workspace => workspace.id)
|
||||
// Frame-dedup baseline, same posture as committedWorkspaceIds: the
|
||||
// stream opens against the current set; workspace.list re-baselines
|
||||
// reconnecting clients, so only later changes need frames.
|
||||
@@ -3445,6 +3461,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (change.table === '') {
|
||||
if (change.operation !== 'put') return
|
||||
const state = workspaceDomainState.parse(change.value)
|
||||
const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length
|
||||
&& state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId)))
|
||||
&& state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index])
|
||||
for (const workspaceId of state.workspaceIds) {
|
||||
if (committedWorkspaceIds.has(workspaceId)) continue
|
||||
const workspace = ctx.workspace.get(workspaceId)
|
||||
@@ -3454,6 +3473,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
committedWorkspaceIds.add(workspaceId)
|
||||
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
|
||||
}
|
||||
committedWorkspaceOrder = [...state.workspaceIds]
|
||||
if (orderChanged) {
|
||||
queue.push(frame({
|
||||
type: 'host/workspace-order-changed',
|
||||
workspaceIds: [...state.workspaceIds],
|
||||
}))
|
||||
}
|
||||
if (state.archivedSessionIds.length !== archivedSessionIds.length
|
||||
|| state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) {
|
||||
archivedSessionIds = state.archivedSessionIds
|
||||
|
||||
@@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }),
|
||||
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
|
||||
// args stays wide, the same posture as session/projection's value: the frame
|
||||
// arrives from JSON.parse, so every element is already a JSON value, and the
|
||||
|
||||
@@ -119,7 +119,8 @@ export type MuxFrame =
|
||||
* workspace mutation (create/attach/order change — the client upserts, while
|
||||
* `workspace.list` provides the reconnect baseline); workspace-removed is the
|
||||
* committed registration-deletion increment and never implies directory or
|
||||
* session-log deletion; archived-sessions-changed pushes the full registry
|
||||
* session-log deletion; workspace-order-changed pushes the complete durable
|
||||
* registry order after a reorder; archived-sessions-changed pushes the full registry
|
||||
* archive set after every durable change (same full-snapshot posture as
|
||||
* workspace-changed — `workspace.list` re-baselines it on reconnect).
|
||||
*/
|
||||
@@ -138,6 +139,7 @@ export type HostFrame =
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
|
||||
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
|
||||
| { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] }
|
||||
| { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
|
||||
/**
|
||||
* One allowlisted host cordis event forwarded verbatim. The allowlist is
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface RpcMethodMap {
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.delete': WorkspaceApi['delete']
|
||||
'workspace.insertBefore': WorkspaceApi['insertBefore']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
'workspace.archiveSession': WorkspaceApi['archiveSession']
|
||||
'skill.list': SkillsApi['list']
|
||||
|
||||
@@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({
|
||||
deleted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
|
||||
|
||||
/** workspace.insertBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
beforeWorkspaceId: workspaceIdSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertBefore'>>>
|
||||
|
||||
/** workspace.insertBefore response value: the complete durable display order. */
|
||||
export const workspaceInsertBeforeValueSchema = z.object({
|
||||
workspaceIds: z.array(workspaceIdSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertBefore'>>>
|
||||
|
||||
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertSessionBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
|
||||
@@ -73,6 +73,15 @@ export interface WorkspaceApi {
|
||||
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
|
||||
Promise<RpcResponse<{ deleted: true }>>
|
||||
|
||||
/**
|
||||
* Moves one Workspace within the registry display order,
|
||||
* DOM-insertBefore-like. An omitted anchor appends to the end.
|
||||
*/
|
||||
insertBefore(request: RpcRequest<{
|
||||
workspaceId: WorkspaceId
|
||||
beforeWorkspaceId?: WorkspaceId
|
||||
}>): Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>>
|
||||
|
||||
/**
|
||||
* Moves an accounted session within its workspace's manual order,
|
||||
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
workspaceArchiveSessionValueSchema,
|
||||
workspaceCreateValueSchema,
|
||||
workspaceDeleteValueSchema,
|
||||
workspaceInsertBeforeValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
@@ -116,6 +117,7 @@ export interface IApiClient {
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
|
||||
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
|
||||
insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertBefore'>>>
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
|
||||
}
|
||||
@@ -193,6 +195,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.delete': workspaceDeleteValueSchema,
|
||||
'workspace.insertBefore': workspaceInsertBeforeValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'workspace.archiveSession': workspaceArchiveSessionValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
@@ -445,6 +448,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
|
||||
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
|
||||
insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal),
|
||||
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
|
||||
archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal),
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
workspaceArchiveSessionRequestSchema,
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceDeleteRequestSchema,
|
||||
workspaceInsertBeforeRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
@@ -112,6 +113,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
||||
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
|
||||
'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) },
|
||||
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
||||
'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
|
||||
@@ -319,6 +319,50 @@ describe('workspace.create', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace.insertBefore', () => {
|
||||
it('commits the complete order, streams one order frame, and maps unknown ids', async () => {
|
||||
const { api, ctx, root } = await harness()
|
||||
const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace
|
||||
const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace
|
||||
const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace
|
||||
|
||||
const abort = new AbortController()
|
||||
const listWorkspaces = vi.spyOn(ctx.workspace, 'list')
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
expect(listWorkspaces).toHaveBeenCalledTimes(1)
|
||||
const changed = nextHostFrame(stream)
|
||||
const reordered = expectOk(await api.workspace.insertBefore(request({
|
||||
workspaceId: first.workspaceId,
|
||||
beforeWorkspaceId: second.workspaceId,
|
||||
})))
|
||||
expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId])
|
||||
expect(await changed).toMatchObject({
|
||||
payload: {
|
||||
type: 'host/workspace-order-changed',
|
||||
workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId],
|
||||
},
|
||||
})
|
||||
expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId))
|
||||
.toEqual(reordered.workspaceIds)
|
||||
|
||||
const missingSource = await api.workspace.insertBefore(request({
|
||||
workspaceId: 'missing' as WorkspaceId,
|
||||
}))
|
||||
expect(missingSource.result).toMatchObject({
|
||||
ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } },
|
||||
})
|
||||
const missingAnchor = await api.workspace.insertBefore(request({
|
||||
workspaceId: first.workspaceId,
|
||||
beforeWorkspaceId: 'missing-anchor' as WorkspaceId,
|
||||
}))
|
||||
expect(missingAnchor.result).toMatchObject({
|
||||
ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } },
|
||||
})
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session creation and Workspace membership', () => {
|
||||
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
|
||||
const { api, ctx, root } = await harness()
|
||||
|
||||
@@ -85,6 +85,7 @@ function scriptedApi(overrides: {
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
delete: r => ok(r, { deleted: true as const }),
|
||||
insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
|
||||
},
|
||||
@@ -218,7 +219,7 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } })
|
||||
})
|
||||
|
||||
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
|
||||
it('routes workspace rename, delete, and ordering through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
|
||||
@@ -227,6 +228,11 @@ describe('unary round trip', () => {
|
||||
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
const deleted = await c.workspace.delete({ workspaceId: 'w1' as never })
|
||||
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
|
||||
const workspaceOrder = await c.workspace.insertBefore({
|
||||
workspaceId: 'w1' as never,
|
||||
beforeWorkspaceId: 'w2' as never,
|
||||
})
|
||||
expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } })
|
||||
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
|
||||
expect(anchored.result.ok).toBe(true)
|
||||
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
|
||||
|
||||
@@ -179,6 +179,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async delete(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
|
||||
},
|
||||
async insertBefore(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } }
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema,
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
|
||||
workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
@@ -394,6 +395,17 @@ describe('workspace domain schemas', () => {
|
||||
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
|
||||
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
|
||||
})
|
||||
|
||||
it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => {
|
||||
expect(workspaceInsertBeforeRequestSchema.parse({
|
||||
workspaceId: 'w1', beforeWorkspaceId: 'w2',
|
||||
}).beforeWorkspaceId).toBe('w2')
|
||||
expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId)
|
||||
.toBeUndefined()
|
||||
expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow()
|
||||
expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds)
|
||||
.toEqual(['w2', 'w1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills domain schemas', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
|
||||
README.md: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d
|
||||
README.zh.md: 74dc44431297fb80718e23bfeb33ea6e9ec9b926
|
||||
README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967
|
||||
README.zh.md: 5283e268af16446022bf47395fc8ffb1954581a1
|
||||
|
||||
@@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
|
||||
- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all.
|
||||
- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
|
||||
@@ -86,9 +87,20 @@ Every read failure degrades to no metadata — absent, malformed, wrongly typed,
|
||||
|---|---|---|
|
||||
| `default` | required | Preset id mounted when a caller names none |
|
||||
| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
|
||||
| `includeUserRoot` | `true` | Append `<dshHome>/.agent-presets` as a `user` root, after every configured root |
|
||||
|
||||
An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution.
|
||||
|
||||
### The writable root is this package's, the shipped root is the app's
|
||||
|
||||
`<dshHome>/.agent-presets` is where a person's own presets live, the way `<dshHome>/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve.
|
||||
|
||||
The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw.
|
||||
|
||||
`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `<dshHome>` decides what the roster contains.
|
||||
|
||||
The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve.
|
||||
|
||||
### The default preset is a user setting
|
||||
|
||||
When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default:
|
||||
@@ -132,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`.
|
||||
- **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
|
||||
- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts.
|
||||
- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`.
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
|
||||
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
|
||||
- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。
|
||||
- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。
|
||||
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
|
||||
@@ -86,9 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen
|
||||
|---|---|---|
|
||||
| `default` | 必填 | 调用方未指定时挂载的 preset id |
|
||||
| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
|
||||
| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `<dshHome>/.agent-presets` 作为 `user` 根目录 |
|
||||
|
||||
根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
|
||||
|
||||
### 可写根目录属于本包,随附根目录属于 app
|
||||
|
||||
`<dshHome>/.agent-presets` 是个人自有 preset 的所在,正如 `<dshHome>/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。
|
||||
|
||||
根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。
|
||||
|
||||
`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `<dshHome>` 决定 roster 的内容。
|
||||
|
||||
随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。
|
||||
|
||||
### 默认 preset 是一项用户设置
|
||||
|
||||
当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值:
|
||||
@@ -132,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。
|
||||
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
|
||||
- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。
|
||||
|
||||
@@ -25,6 +25,21 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
|
||||
/** The composition file that makes a directory a preset. */
|
||||
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
||||
|
||||
/**
|
||||
* Harness-home directory holding locally authored presets.
|
||||
*
|
||||
* This package owns the writable root the way `dsh-skill-local` owns
|
||||
* `<dshHome>/skills`. An app must assemble the SHIPPED root, whose path only
|
||||
* the installed app can resolve; where a person's own presets go is the same
|
||||
* place in every deployment that does not say otherwise, so a launcher that
|
||||
* forgets to configure one still finds them.
|
||||
*
|
||||
* Package-internal on purpose: no consumer outside this package addresses the
|
||||
* directory by name, and a test that imported it could not catch this value
|
||||
* being wrong — the expected segment is spelled out where it is asserted.
|
||||
*/
|
||||
export const USER_PRESET_DIR = '.agent-presets'
|
||||
|
||||
/**
|
||||
* Why `rows` cannot be an entry list, or undefined when it can.
|
||||
*
|
||||
|
||||
@@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type
|
||||
// Type-only: resolves the `agent/created` lifecycle event this service watches.
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { discoverPresets, USER_PRESET_DIR } from './discovery.ts'
|
||||
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
|
||||
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
|
||||
import { PresetExistsError } from './authoring.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
|
||||
import type {} from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
@@ -88,8 +89,21 @@ export class AgentPresets extends Service {
|
||||
path: z.string().required(),
|
||||
trust: z.union(['system', 'user'] as const).default('user'),
|
||||
})).default([]),
|
||||
includeUserRoot: z.boolean().default(true),
|
||||
}) as z<Config>
|
||||
|
||||
/**
|
||||
* The roots discovery and authoring actually scan: every configured root in
|
||||
* order, then the harness-home user root unless `includeUserRoot` is false.
|
||||
*
|
||||
* Derived once, because a root set that changed between `list()` and the
|
||||
* `copy()` acting on its answer would author into a directory the caller
|
||||
* never saw. Appending rather than prepending keeps an earlier configured
|
||||
* root winning a duplicate id, so a shipped preset still shadows a
|
||||
* locally authored directory that claimed its name.
|
||||
*/
|
||||
private readonly resolvedRoots: readonly PresetRoot[]
|
||||
|
||||
/**
|
||||
* The user layer over `config.default`, present only while a settings
|
||||
* provider is composed. Held rather than snapshotted so a hot-reloaded
|
||||
@@ -116,6 +130,9 @@ export class AgentPresets extends Service {
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
this.selfCtx = ctx
|
||||
this.resolvedRoots = config.includeUserRoot
|
||||
? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }]
|
||||
: [...config.roots]
|
||||
// Deliberately not `installSettingsSection`: that helper exists to re-judge
|
||||
// what a consumer DERIVED from the source — memoized resolutions,
|
||||
// registration-level facts — across attach, detach, and change. Nothing
|
||||
@@ -147,7 +164,7 @@ export class AgentPresets extends Service {
|
||||
// does that today — the Web surface mounts in `setup` and children join
|
||||
// through `composeFrom` before publication.
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
if (this.config.roots.length === 0) return
|
||||
if (this.resolvedRoots.length === 0) return
|
||||
if (this.composedPreset(agent.ctx) !== undefined) return
|
||||
ctx.logger.warn(
|
||||
`agent "${agent.id}" was published without joining an agent preset; `
|
||||
@@ -180,7 +197,7 @@ export class AgentPresets extends Service {
|
||||
* @returns the presets, first-root-wins per id.
|
||||
*/
|
||||
async list(): Promise<AgentPreset[]> {
|
||||
return await discoverPresets(this.config.roots)
|
||||
return await discoverPresets(this.resolvedRoots)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,9 +337,19 @@ export class AgentPresets extends Service {
|
||||
return standingMountFor(agentCtx)?.presetId
|
||||
}
|
||||
|
||||
/** Whether this deployment configures a root locally authored presets go to. */
|
||||
/**
|
||||
* The roots this roster scans, which is not `config.roots`: it is every
|
||||
* configured root in order, then the harness-home user root unless
|
||||
* `includeUserRoot` is false. Read this — not the config field — to answer
|
||||
* whether a roster is composed at all, so one derivation decides it.
|
||||
*/
|
||||
get roots(): readonly PresetRoot[] {
|
||||
return this.resolvedRoots
|
||||
}
|
||||
|
||||
/** Whether this deployment has a root locally authored presets go to. */
|
||||
get authorable(): boolean {
|
||||
return this.config.roots.some(root => root.trust === 'user')
|
||||
return this.resolvedRoots.some(root => root.trust === 'user')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,7 +385,7 @@ export class AgentPresets extends Service {
|
||||
if ((await this.list()).some(preset => preset.id === id)) {
|
||||
throw new PresetExistsError(id)
|
||||
}
|
||||
await copyComposition(this.config.roots, source, id, name)
|
||||
await copyComposition(this.resolvedRoots, source, id, name)
|
||||
// A settled mount under this id can only be stale (its preset was deleted
|
||||
// from disk outside `remove`); the new preset must not inherit it. Every
|
||||
// session already joined keeps the generation it runs on regardless.
|
||||
@@ -371,7 +398,7 @@ export class AgentPresets extends Service {
|
||||
* @throws when the preset is unknown or ships with the deployment.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await deleteComposition(this.config.roots, await this.resolve(id))
|
||||
await deleteComposition(this.resolvedRoots, await this.resolve(id))
|
||||
// Sessions on the deleted preset keep their standing mount; only new
|
||||
// sessions see the roster without it.
|
||||
this.standing.delete(id)
|
||||
|
||||
@@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('system-prompt/assemble', (_assembly, context, next) => {
|
||||
const presets = ctx.get('agentPresets')
|
||||
const agent = context.agent
|
||||
if (presets !== undefined && presets.config.roots.length > 0
|
||||
if (presets !== undefined && presets.roots.length > 0
|
||||
&& agent !== undefined && presets.composedPreset(agent.ctx) === undefined) {
|
||||
fail(
|
||||
`agent "${agent.id}" addressed a model without joining any agent preset while a roster is `
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface Config {
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
/**
|
||||
* Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
|
||||
* configured root. False mounts a roster over `roots` alone.
|
||||
*/
|
||||
includeUserRoot: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,10 @@ beforeEach(async () => {
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: userRoot, trust: 'user' as const },
|
||||
],
|
||||
// Every roster in this file pins its own roots: the derived harness-home
|
||||
// root would add the developer's real presets to what these assertions
|
||||
// count, and `copy` would write into it.
|
||||
includeUserRoot: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => {
|
||||
{ path: userRoot, trust: 'user' as const },
|
||||
{ path: second, trust: 'user' as const },
|
||||
],
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
// Writes go to the first user root, so a preset discovered from a later
|
||||
@@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => {
|
||||
await readOnly.plugin(AgentPresets, {
|
||||
default: 'standard',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }],
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
expect(readOnly.agentPresets.authorable).toBe(false)
|
||||
@@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => {
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: absent, trust: 'user' as const },
|
||||
],
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
await fresh.agentPresets.copy('standard', 'mine')
|
||||
|
||||
@@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets'
|
||||
import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
@@ -20,7 +20,7 @@ const ROOTS = [
|
||||
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
|
||||
]
|
||||
|
||||
async function harness(): Promise<Context> {
|
||||
async function harness(roster: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
@@ -31,7 +31,7 @@ async function harness(): Promise<Context> {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentPresetsInvariant)
|
||||
return ctx
|
||||
@@ -97,6 +97,28 @@ describe('agent-presets invariants', () => {
|
||||
.rejects.toThrow(/without joining any agent preset/)
|
||||
})
|
||||
|
||||
it('rejects one just the same when the derived home root is the whole roster', async () => {
|
||||
// The shape this plugin defaults to: an app configures nothing and the
|
||||
// roster is the harness home alone. A roster is a roster however its roots
|
||||
// were resolved, so the fail-loud half must not go quiet here — it read
|
||||
// `config.roots` once, which is empty in exactly this case.
|
||||
const ctx = await harness({ roots: [], includeUserRoot: true })
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') })
|
||||
|
||||
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent)))
|
||||
.rejects.toThrow(/without joining any agent preset/)
|
||||
})
|
||||
|
||||
it('stays silent for a composition that opted out of every root', async () => {
|
||||
// `includeUserRoot: false` with no configured roots is a deployment that
|
||||
// mounts the roster but keeps its agents on the host plane; there is no
|
||||
// roster to join, so an unjoined agent is not a violation.
|
||||
const ctx = await harness({ roots: [], includeUserRoot: false })
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') })
|
||||
|
||||
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('admits a joined agent, a scopeless read, and a standing-key read', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
|
||||
@@ -38,7 +38,7 @@ const ROOTS = [
|
||||
* @param roster - roster config, defaulting to the fixture roots.
|
||||
* @returns the booted context.
|
||||
*/
|
||||
async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise<Context> {
|
||||
async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
@@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => {
|
||||
join(presetDir, COMPOSITION_FILE),
|
||||
`- id: only\n name: ${plugin}\n config:\n tool: absolute\n`,
|
||||
)
|
||||
const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] })
|
||||
const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false })
|
||||
const imported = vi.spyOn(scoped.loader.internal!, 'import')
|
||||
|
||||
await agentOn(scoped, 'sess-absolute-plugin')
|
||||
@@ -347,7 +347,7 @@ describe('composing from a broken preset', () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
|
||||
await mkdir(join(root, 'damaged'))
|
||||
await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
|
||||
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] })
|
||||
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
}
|
||||
|
||||
it('refuses the mount up front with the discovery-reported reason', async () => {
|
||||
@@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => {
|
||||
it('says so instead of naming an empty list of candidates', async () => {
|
||||
const bare = new Context()
|
||||
await bare.plugin(Loader)
|
||||
await bare.plugin(AgentPresets, { default: 'standard', roots: [] })
|
||||
await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false })
|
||||
|
||||
await expect(bare.agentPresets.resolve())
|
||||
.rejects.toThrow(/preset "standard" not found \(available: none\)/)
|
||||
@@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => {
|
||||
await scoped.plugin(ToolRegistry)
|
||||
await scoped.plugin(AgentRegistry)
|
||||
await scoped.plugin(AgentLoop, { agents: [] })
|
||||
await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] })
|
||||
await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
|
||||
await scoped.agents.create({
|
||||
sessionId: SessionId('sess-self-dispose'),
|
||||
@@ -528,11 +528,13 @@ describe('replacing a composition', () => {
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('says nothing when the deployment configures no roster at all', async () => {
|
||||
it('says nothing when the composition opts out of every root', async () => {
|
||||
// Presets are optional: every surface except the Web bundle keeps its
|
||||
// model-facing rows in the host plane, so an agent with a chain of one is
|
||||
// exactly right there and the diagnostic must stay silent.
|
||||
const rosterless = await harness({ default: 'standard', roots: [] })
|
||||
// exactly right there and the diagnostic must stay silent. Opting out is
|
||||
// what makes this rosterless — empty `roots` alone would still derive the
|
||||
// harness-home root, which is a roster like any other.
|
||||
const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false })
|
||||
const warnings: string[] = []
|
||||
rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn
|
||||
|
||||
@@ -581,7 +583,7 @@ describe('replacing a composition', () => {
|
||||
await scoped.plugin(ToolRegistry)
|
||||
await scoped.plugin(AgentRegistry)
|
||||
await scoped.plugin(AgentLoop, { agents: [] })
|
||||
await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] })
|
||||
await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
const handle = await scoped.agents.create({
|
||||
sessionId: SessionId('sess-restore-gone'),
|
||||
setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'),
|
||||
@@ -621,7 +623,7 @@ describe('editing a composition file', () => {
|
||||
await mkdir(join(root, id))
|
||||
const path = join(root, id, COMPOSITION_FILE)
|
||||
await writeFile(path, rowFor('before'))
|
||||
const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] })
|
||||
const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
return { scoped, path }
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ async function harness(
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false })
|
||||
await settingsFiber
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false })
|
||||
return { ctx, settingsFile, settingsFiber }
|
||||
}
|
||||
|
||||
|
||||
131
packages/preset/agent-presets/tests/user-root.spec.ts
Normal file
131
packages/preset/agent-presets/tests/user-root.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The writable root is this package's own, not an assembly fact each app must
|
||||
* remember: a roster configured with only a `system` root still discovers and
|
||||
* authors into `<dshHome>/.agent-presets`, the way `dsh-skill-local` owns
|
||||
* `<dshHome>/skills`. `includeUserRoot: false` is how a deployment — or a test
|
||||
* pinning an exact roster — opts out.
|
||||
*
|
||||
* `$DSH_HOME` is repointed per test because the derived root is resolved in the
|
||||
* constructor: the plugin must be mounted while the environment names the
|
||||
* temporary home, or it would reach the developer's real one.
|
||||
*/
|
||||
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const SYSTEM_ROOT = join(FIXTURES, 'system')
|
||||
/** Spelled out rather than imported: the convention is what these tests assert. */
|
||||
const USER_ROOT_SEGMENT = '.agent-presets'
|
||||
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
|
||||
|
||||
let home: string
|
||||
let previousHome: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-'))
|
||||
previousHome = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = home
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousHome === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previousHome
|
||||
})
|
||||
|
||||
/** Boot a roster over the fixture system root, with the derived root left to the plugin. */
|
||||
async function roster(config: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(AgentPresets, {
|
||||
default: 'standard',
|
||||
roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }],
|
||||
includeUserRoot: true,
|
||||
...config,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Hand-place a preset directory under the harness home's preset root. */
|
||||
async function seedHomePreset(id: string): Promise<void> {
|
||||
await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true })
|
||||
await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID)
|
||||
}
|
||||
|
||||
describe('the harness-home preset root', () => {
|
||||
it('is what a roster gets when config names no roots at all', () => {
|
||||
// The schema default is the contract an app relies on by saying nothing;
|
||||
// every other case here passes the field explicitly. The cast stands for
|
||||
// the untyped document the Loader hands the schema, which is where a
|
||||
// composition that omits the key actually comes from.
|
||||
const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config)
|
||||
|
||||
expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] })
|
||||
})
|
||||
|
||||
it('is discovered without any app configuring it', async () => {
|
||||
await seedHomePreset('mine')
|
||||
const ctx = await roster()
|
||||
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' })
|
||||
expect((await ctx.agentPresets.resolve('mine')).path)
|
||||
.toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE))
|
||||
})
|
||||
|
||||
it('makes a roster with only a system root authorable, and receives the copy', async () => {
|
||||
const ctx = await roster()
|
||||
|
||||
expect(ctx.agentPresets.authorable).toBe(true)
|
||||
await ctx.agentPresets.copy('standard', 'copied')
|
||||
|
||||
expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts after every configured root, so a shipped id still shadows a home directory', async () => {
|
||||
// `standard` exists in the fixture system root; claiming the name at home
|
||||
// must not take it over, because `copy` refuses an id any root supplies
|
||||
// and a session resolving `standard` must reach the shipped composition.
|
||||
await seedHomePreset('standard')
|
||||
const ctx = await roster()
|
||||
|
||||
expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system')
|
||||
await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/)
|
||||
})
|
||||
|
||||
it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => {
|
||||
await seedHomePreset('mine')
|
||||
const ctx = await roster({ includeUserRoot: false })
|
||||
|
||||
expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine')
|
||||
expect(ctx.agentPresets.authorable).toBe(false)
|
||||
await expect(ctx.agentPresets.copy('standard', 'mine'))
|
||||
.rejects.toThrow(/no user-writable preset root/)
|
||||
})
|
||||
|
||||
it('yields to a configured user root for authoring, which writableRoot takes first', async () => {
|
||||
const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-'))
|
||||
const ctx = await roster({
|
||||
roots: [
|
||||
{ path: SYSTEM_ROOT, trust: 'system' as const },
|
||||
{ path: explicit, trust: 'user' as const },
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.agentPresets.copy('standard', 'copied')
|
||||
|
||||
expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true)
|
||||
expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1384,6 +1384,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'delete(id: WorkspaceId): Promise<boolean>',
|
||||
jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly WorkspaceId[]>',
|
||||
jsDoc: '/**\n * Move one workspace within the durable display order, DOM-insertBefore-like.\n * With an anchor it lands before that workspace; without one it appends.\n * @param id - Workspace to move.\n * @param beforeId - Workspace anchor; omitted appends.\n * @returns the complete committed workspace order.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'archiveSession(sessionId: SessionId): Promise<void>',
|
||||
jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */',
|
||||
|
||||
@@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter;
|
||||
ctx.loader.builtins.include = Include
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS })
|
||||
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false })
|
||||
const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
|
||||
README.md: 057765e38de9cc700210eb8edeb1ddc7ffc861ff
|
||||
README.zh.md: 7416875dbf2ee1652f6e1fa1663144d7407a1ae7
|
||||
README.md: 4f7e2925ca7572dc3cc32c2a294bd1f40b243254
|
||||
README.zh.md: 2f4f38dea881b2c8a2bb135c8f7b1b3c88b9190a
|
||||
|
||||
@@ -10,9 +10,10 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
|
||||
|
||||
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title.
|
||||
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
|
||||
- `ctx.workspace.insertBefore(id, before?)` — moves a registered Workspace within durable registry order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A source or anchor absent from the registry rejects without writing; a self-anchor or move to the current position resolves without writing. The returned id list is the complete committed order.
|
||||
- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity.
|
||||
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.
|
||||
- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Workspace order never changes.
|
||||
- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Registry Workspace order never changes.
|
||||
- `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set.
|
||||
- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup.
|
||||
- `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record.
|
||||
|
||||
@@ -10,9 +10,10 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。
|
||||
- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。
|
||||
- `ctx.workspace.insertBefore(id, before?)`:在持久注册表顺序内移动一个已注册 Workspace,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。来源或锚点不在注册表中时拒绝且不写入;以自身为锚点或移动到当前位置时直接完成且不写入。返回的 id 列表是完整的已提交顺序。
|
||||
- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。
|
||||
- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。
|
||||
- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。Workspace 顺序绝不改变。
|
||||
- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。注册表中的 Workspace 顺序绝不改变。
|
||||
- `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。
|
||||
- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。
|
||||
- `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。
|
||||
|
||||
@@ -52,6 +52,17 @@ export class WorkspaceUnknownSessionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A workspace reorder named a source or anchor absent from the durable registry order. */
|
||||
export class WorkspaceOrderInvalidError extends Error {
|
||||
/**
|
||||
* @param workspaceId - Missing source or anchor id.
|
||||
*/
|
||||
constructor(readonly workspaceId: WorkspaceId) {
|
||||
super(`cannot reorder unknown workspace '${workspaceId}'`)
|
||||
this.name = 'WorkspaceOrderInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -189,6 +200,30 @@ export class WorkspaceRegistry extends Service {
|
||||
return this.enqueueOperation(() => this.deleteKnown(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one workspace within the durable display order, DOM-insertBefore-like.
|
||||
* With an anchor it lands before that workspace; without one it appends.
|
||||
* @param id - Workspace to move.
|
||||
* @param beforeId - Workspace anchor; omitted appends.
|
||||
* @returns the complete committed workspace order.
|
||||
*/
|
||||
insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly WorkspaceId[]> {
|
||||
return this.enqueueOperation(async () => {
|
||||
const state = this.requireState()
|
||||
if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id)
|
||||
if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) {
|
||||
throw new WorkspaceOrderInvalidError(beforeId)
|
||||
}
|
||||
if (beforeId === id) return state.workspaceIds
|
||||
const without = state.workspaceIds.filter(workspaceId => workspaceId !== id)
|
||||
const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
|
||||
const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)]
|
||||
if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds
|
||||
await this.setState({ ...state, workspaceIds })
|
||||
return workspaceIds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry-global archive set: sessions hidden from every grouping
|
||||
* surface. Archiving never touches workspace accounting — an archived
|
||||
|
||||
@@ -10,7 +10,11 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts'
|
||||
import WorkspaceRegistry, {
|
||||
WorkspaceId,
|
||||
WorkspaceMoveInvalidError,
|
||||
WorkspaceOrderInvalidError,
|
||||
} from '../src/index.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
|
||||
const DOMAIN_VERSION = 2
|
||||
@@ -568,6 +572,49 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace registry ordering', () => {
|
||||
it('moves a workspace before an anchor or to the end and restores that order after restart', async () => {
|
||||
const firstDir = await makeDir('order-first')
|
||||
const secondDir = await makeDir('order-second')
|
||||
const thirdDir = await makeDir('order-third')
|
||||
const result = await harness()
|
||||
const first = await result.registry.create(firstDir)
|
||||
const second = await result.registry.create(secondDir)
|
||||
const third = await result.registry.create(thirdDir)
|
||||
expect(result.registry.list().map(item => item.id)).toEqual([third.id, second.id, first.id])
|
||||
|
||||
await expect(result.registry.insertBefore(first.id, second.id))
|
||||
.resolves.toEqual([third.id, first.id, second.id])
|
||||
await expect(result.registry.insertBefore(third.id))
|
||||
.resolves.toEqual([first.id, second.id, third.id])
|
||||
expect(storedState(result.pool).workspaceIds).toEqual([first.id, second.id, third.id])
|
||||
|
||||
const restarted = await harness({ pool: result.pool })
|
||||
expect(restarted.registry.list().map(item => item.id)).toEqual([first.id, second.id, third.id])
|
||||
})
|
||||
|
||||
it('keeps self-anchored and already-positioned moves write-free and rejects unknown ids', async () => {
|
||||
const firstDir = await makeDir('order-noop-first')
|
||||
const secondDir = await makeDir('order-noop-second')
|
||||
const result = await harness()
|
||||
const first = await result.registry.create(firstDir)
|
||||
const second = await result.registry.create(secondDir)
|
||||
const written = result.changes.length
|
||||
|
||||
await result.registry.insertBefore(second.id, second.id)
|
||||
await result.registry.insertBefore(second.id, first.id)
|
||||
await result.registry.insertBefore(first.id)
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(result.registry.list().map(item => item.id)).toEqual([second.id, first.id])
|
||||
|
||||
await expect(result.registry.insertBefore(WorkspaceId('missing')))
|
||||
.rejects.toBeInstanceOf(WorkspaceOrderInvalidError)
|
||||
await expect(result.registry.insertBefore(second.id, WorkspaceId('missing-anchor')))
|
||||
.rejects.toMatchObject({ workspaceId: 'missing-anchor' })
|
||||
expect(result.changes).toHaveLength(written)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace session ordering', () => {
|
||||
it('prepends new attaches and keeps repeat attach idempotent', async () => {
|
||||
const dir = await makeDir('attach-order')
|
||||
|
||||
Reference in New Issue
Block a user