Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Keep both Remote contributions master and this branch add: the mount loop now carries commandsRemote, goalsRemote, pluginInventoryRemote, and messageFeedbackRemote, with both new tsconfig references retained.
This commit is contained in:
@@ -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/api/remotes/README.md
|
||||
README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e
|
||||
README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a
|
||||
README.md: 288c63c9f43654dfec428a6a8955dc537efe81a6
|
||||
README.zh.md: 1fd599b08ecc1b4ae1dba738ce8946aa4eab5946
|
||||
|
||||
@@ -6,7 +6,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
|
||||
|
||||
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
|
||||
|
||||
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
|
||||
The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
|
||||
|
||||
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
|
||||
|
||||
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
|
||||
|
||||
当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -79,6 +80,7 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
|
||||
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
|
||||
export type {} from '@deepseek-ai/dsh-commands/remote'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
|
||||
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
// The forwarded-event allowlist's selection seat: without it in the consumer's
|
||||
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
|
||||
@@ -56,7 +59,7 @@ export const inject = ['remote']
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
const disposers: Array<() => Promise<void>> = []
|
||||
try {
|
||||
for (const contribution of [commandsRemote, goalsRemote, messageFeedbackRemote]) {
|
||||
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote, messageFeedbackRemote]) {
|
||||
disposers.push(await ctx.remote.$mount(contribution))
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../feedback/message-feedback"
|
||||
},
|
||||
{
|
||||
"path": "../../host/plugin-inventory"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
|
||||
@@ -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/boot/cmdline/README.md
|
||||
README.md: 2e8e58b23785fa78bd2663a459817669309a81be
|
||||
README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd
|
||||
README.md: 33125014539e801dbd2952a3b4513cafc80bdcee
|
||||
README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559
|
||||
|
||||
@@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones
|
||||
|
||||
## Ordinary providers and injected config
|
||||
|
||||
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service:
|
||||
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide('webStartup', values)
|
||||
const program = webCommand()
|
||||
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -45,7 +46,7 @@ Every row configured from those values uses ordinary service injection and direc
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
```
|
||||
|
||||
`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate.
|
||||
`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate.
|
||||
|
||||
### How injection orders config
|
||||
|
||||
|
||||
@@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
|
||||
|
||||
## 普通提供方与注入配置
|
||||
|
||||
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有:
|
||||
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide('webStartup', values)
|
||||
const program = webCommand()
|
||||
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -45,7 +46,7 @@ export function apply(ctx: Context): void {
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
```
|
||||
|
||||
`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。
|
||||
`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。
|
||||
|
||||
### 注入如何排列配置求值
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* text, and its parse errors instead of the launcher knowing them.
|
||||
*
|
||||
* Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
|
||||
* provider may publish the parsed values as its own service, and ordinary rows
|
||||
* provider may publish the parsed values as its own service from its program's
|
||||
* commander action, and ordinary rows
|
||||
* can inject that service and read it from lazily resolved config —
|
||||
* `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written
|
||||
* beside it. No row has launcher-level command-line status.
|
||||
@@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
|
||||
stderr: process.stderr,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve parsed arguments into an app-owned value. Call
|
||||
* `program.error(...)` to reject the invocation with a usage message instead
|
||||
* of throwing.
|
||||
* @param program - the parsed commander program.
|
||||
* @param ctx - the plugin context that received the command line.
|
||||
* @returns the value an ordinary provider plugin may publish.
|
||||
*/
|
||||
export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
|
||||
|
||||
/**
|
||||
* Parse the launcher's immutable argument snapshot with an app's commander
|
||||
* program. The caller decides whether and how to publish the returned value;
|
||||
* this helper has no Loader-row or service ownership semantics.
|
||||
* program. Commander runs the program's own synchronous action handler on a
|
||||
* successful parse; app code there publishes its service and rejects an
|
||||
* invalid invocation with `program.error(...)`. This helper has no Loader-row
|
||||
* or service ownership semantics.
|
||||
*
|
||||
* Help, version, and rejected arguments are terminal for the process: commander
|
||||
* writes the text, the helper requests `ctx.appExit`, and it returns
|
||||
* `undefined` so the caller publishes nothing.
|
||||
* Help, version, and rejected arguments — from the grammar or from an action
|
||||
* — are terminal for the process: commander writes the text and the helper
|
||||
* requests `ctx.appExit`. The action never runs on help, version, or a
|
||||
* grammar rejection; an action must reject before it publishes, because
|
||||
* statements before its `program.error(...)` have already run.
|
||||
* @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
|
||||
* @param program - the app's commander program, with its flags and description already declared.
|
||||
* @param plan - this invocation's resolved value; omitted returns an empty object.
|
||||
* @returns the resolved value, or `undefined` when the app asked to exit.
|
||||
* @throws when the launcher did not provide the command line and exit request.
|
||||
* @param program - the app's commander program, with its flags, description,
|
||||
* actions, and any subcommands already declared.
|
||||
* @throws when the launcher did not provide the command line and exit request,
|
||||
* or when no command in the program declares an action.
|
||||
*/
|
||||
export function parseCmdline<T>(
|
||||
ctx: Context,
|
||||
program: Command,
|
||||
plan: CmdlinePlan<T> = (() => ({}) as T),
|
||||
): T | undefined {
|
||||
export function parseCmdline(ctx: Context, program: Command): void {
|
||||
// Read through the global service store, not the property proxy: appExit is
|
||||
// an optional host value and the plugin only needs to inject cmdlineArgs.
|
||||
const args = ctx.get('cmdlineArgs')
|
||||
@@ -112,23 +103,54 @@ export function parseCmdline<T>(
|
||||
if (args === undefined || exit === undefined) {
|
||||
throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
|
||||
}
|
||||
program
|
||||
if (!hasAction(program)) {
|
||||
throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`)
|
||||
}
|
||||
configureExitAndOutput(program)
|
||||
try {
|
||||
program.parse(args.get(), { from: 'user' })
|
||||
} catch (error) {
|
||||
// exitOverride turns help, version, a parse error, and the action's own
|
||||
// program.error() into a CommanderError; commander has already written the
|
||||
// text through the output configured above.
|
||||
if (!isCommanderError(error)) throw error
|
||||
exit(error.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any command in the tree declares an action handler.
|
||||
*
|
||||
* The `Command` type cannot express the action precondition, so the handler is
|
||||
* read structurally (as {@link isCommanderError} reads commander's control-flow
|
||||
* errors): without this guard, a program that forgot its action would parse
|
||||
* successfully, publish nothing, and surface only as dependent rows pending on
|
||||
* the absent service.
|
||||
* @param command - the command whose tree is inspected.
|
||||
* @returns true when the command or any registered subcommand has an action.
|
||||
*/
|
||||
function hasAction(command: Command): boolean {
|
||||
if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true
|
||||
return command.commands.some(hasAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route every command's exit and output through the launcher adapter.
|
||||
*
|
||||
* Commander copies `exitOverride` and output configuration into a subcommand
|
||||
* only at registration, so a root-only override would let an
|
||||
* already-registered subcommand's rejection write to the process streams and
|
||||
* call `process.exit` directly, bypassing `ctx.appExit`.
|
||||
* @param command - the root of the command tree to configure.
|
||||
*/
|
||||
function configureExitAndOutput(command: Command): void {
|
||||
command
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
writeOut: text => void internals.stdout.write(text),
|
||||
writeErr: text => void internals.stderr.write(text),
|
||||
})
|
||||
try {
|
||||
program.parse(args.get(), { from: 'user' })
|
||||
return plan(program, ctx)
|
||||
} catch (error) {
|
||||
// exitOverride turns help, version, a parse error, and a plan's own
|
||||
// program.error() into a CommanderError; commander has already written the
|
||||
// text through the output configured above.
|
||||
if (!isCommanderError(error)) throw error
|
||||
exit(error.exitCode)
|
||||
return undefined
|
||||
}
|
||||
for (const child of command.commands) configureExitAndOutput(child)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts'
|
||||
import { internals, parseCmdline, provideCmdline } from '../src/index.ts'
|
||||
|
||||
/** Every value one boot of the fixture tree observed. */
|
||||
interface Observed {
|
||||
@@ -43,8 +43,8 @@ function demoCommand(): Command {
|
||||
return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
|
||||
}
|
||||
|
||||
/** The fixture app's plan: the resolved values its rows read. */
|
||||
const demoPlan: CmdlinePlan<{ port?: number }> = (program) => {
|
||||
/** The fixture app's action body: the resolved values its rows read. */
|
||||
const resolveDemo = (program: Command): { port?: number } => {
|
||||
const port = program.opts<{ port?: string }>().port
|
||||
if (port === undefined) return {}
|
||||
if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
|
||||
@@ -58,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source })
|
||||
* Mount a two-row composition the way a profile boot does: both rows at once,
|
||||
* with Loader ordering config resolution from their injections.
|
||||
* @param args - the invocation's inner arguments.
|
||||
* @param plan - the app's plan; defaults to the fixture's own.
|
||||
* @param resolve - the app's action body; defaults to the fixture's own.
|
||||
* @returns the booted fixture.
|
||||
*/
|
||||
async function bootFixture(
|
||||
args: string[],
|
||||
plan: CmdlinePlan = demoPlan,
|
||||
resolve: (program: Command) => unknown = resolveDemo,
|
||||
options: { objectInject?: boolean; withoutProvider?: boolean } = {},
|
||||
): Promise<Fixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
|
||||
@@ -88,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
|
||||
const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
|
||||
globals.__observed = observed
|
||||
globals.__provideDemoArgs = (ctx: Context) => {
|
||||
const values = parseCmdline(ctx, demoCommand(), plan)
|
||||
if (values !== undefined) ctx.provide('demoStartup', values)
|
||||
const program = demoCommand()
|
||||
program.action(() => { ctx.provide('demoStartup', resolve(program)) })
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
|
||||
// The composition, exactly as a profile delivers one: include patches whose
|
||||
@@ -133,7 +134,7 @@ describe('parseCmdline', () => {
|
||||
})
|
||||
|
||||
it('recognizes the Loader object form of a provider-service injection', async () => {
|
||||
const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true })
|
||||
const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true })
|
||||
expect(observed.started).toEqual({ port: 8080 })
|
||||
})
|
||||
|
||||
@@ -144,31 +145,35 @@ describe('parseCmdline', () => {
|
||||
expect(observed.exits).toEqual([0])
|
||||
})
|
||||
|
||||
it('rejects the invocation from the plan without starting the app', async () => {
|
||||
it('rejects the invocation from the action without starting the app', async () => {
|
||||
const { observed } = await bootFixture(['--port', 'abc'])
|
||||
expect(observed.out).toContain('--port must be a number')
|
||||
expect(observed.started).toBeUndefined()
|
||||
expect(observed.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('rethrows a plan failure that is not commander asking to exit', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
const plan: CmdlinePlan = () => { throw new Error('plan exploded') }
|
||||
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded')
|
||||
it('rethrows an action failure that is not commander asking to exit', async () => {
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
const program = demoCommand().action(() => { throw new Error('action exploded') })
|
||||
expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded')
|
||||
})
|
||||
|
||||
it('rethrows a thrown value that is not an object at all', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
const plan: CmdlinePlan = () => {
|
||||
const thrown: unknown = 'plan threw a string'
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
const program = demoCommand().action(() => {
|
||||
const thrown: unknown = 'action threw a string'
|
||||
throw thrown
|
||||
}
|
||||
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string')
|
||||
})
|
||||
expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string')
|
||||
})
|
||||
|
||||
it('returns values without inspecting Loader rows or owning a service', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
expect(parseCmdline(ctx, demoCommand())).toEqual({})
|
||||
it('runs the action without inspecting Loader rows or owning a service', async () => {
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
let values: unknown
|
||||
const program = demoCommand()
|
||||
program.action(() => { values = resolveDemo(program) })
|
||||
parseCmdline(ctx, program)
|
||||
expect(values).toEqual({})
|
||||
expect(ctx.get('demoStartup')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -182,6 +187,28 @@ describe('provideCmdline', () => {
|
||||
expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
|
||||
})
|
||||
|
||||
it('refuses at load a program in which no command declares an action', async () => {
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
expect(() => { parseCmdline(ctx, demoCommand()) })
|
||||
.toThrow('no command in the program declares an action')
|
||||
})
|
||||
|
||||
it('routes a pre-registered subcommand rejection through the launcher exit request', () => {
|
||||
const ctx = new Context()
|
||||
const exits: number[] = []
|
||||
let err = ''
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) })
|
||||
// The root declares no action of its own: the tree-wide guard accepts the
|
||||
// subcommand's, and the subcommand inherits the exit and output routing.
|
||||
const program = new Command().name('demo')
|
||||
const child = program.command('serve')
|
||||
child.action(() => { child.error('error: serve rejected') })
|
||||
parseCmdline(ctx, program)
|
||||
expect(err).toContain('serve rejected')
|
||||
expect(exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('fails loud when a parser runs without the launcher values', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { parseCmdline(ctx, demoCommand()) })
|
||||
@@ -191,8 +218,15 @@ describe('provideCmdline', () => {
|
||||
it('lets multiple parsers read the same immutable snapshot', () => {
|
||||
const ctx = new Context()
|
||||
provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
|
||||
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
|
||||
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
|
||||
const parseOnce = (): unknown => {
|
||||
let values: unknown
|
||||
const program = demoCommand()
|
||||
program.action(() => { values = resolveDemo(program) })
|
||||
parseCmdline(ctx, program)
|
||||
return values
|
||||
}
|
||||
expect(parseOnce()).toEqual({ port: 8080 })
|
||||
expect(parseOnce()).toEqual({ port: 8080 })
|
||||
expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: ac5ab10a523fa211c1c1daf4c55d4dc8702eb782
|
||||
README.md: bd38f39f58ee1f765ff34d40cf57cc6daed2b32b
|
||||
README.zh.md: 2c6ff8513bae2b7d4b595733e83223bb7af34780
|
||||
|
||||
@@ -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` 服务,配方不完整会在加载时 fail loud)。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` 服务,配方不完整会在加载时 fail loud)。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'
|
||||
@@ -41,22 +41,17 @@ Examples:
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the parsed command line into the runner's task.
|
||||
* @param program - the parsed headless command.
|
||||
* @returns the runner's service value.
|
||||
*/
|
||||
function planHeadlessStartup(program: Command): HeadlessStartupValues {
|
||||
const task = program.args.join(' ')
|
||||
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
|
||||
return { task }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and provide the one-shot task as an ordinary Cordis service.
|
||||
* Parse and provide the one-shot task as an ordinary Cordis service. The
|
||||
* command's action publishes the task; a missing or whitespace-only task is a
|
||||
* usage error, so on rejection (and on `--help`) nothing is provided.
|
||||
* @param ctx - plugin context carrying the command line.
|
||||
* @returns nothing once the task is provided, or when the command requested exit.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup)
|
||||
if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values)
|
||||
const program = headlessCommand()
|
||||
program.action(() => {
|
||||
const task = program.args.join(' ')
|
||||
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
|
||||
ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues)
|
||||
})
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
- id: directory-picker
|
||||
name: '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
|
||||
# Read-only projection of current Loader entries for trusted client RPCs.
|
||||
- id: plugin-inventory
|
||||
name: '@deepseek-ai/dsh-host-plugin-inventory'
|
||||
|
||||
# The API gateway: the transport-agnostic dispatch face every client shape
|
||||
# shares. The base layer's agent-default-model service owns the default model.
|
||||
- id: api-gateway
|
||||
@@ -172,6 +176,9 @@
|
||||
- id: ui-models
|
||||
name: '@deepseek-ai/dsh-client-ui-models'
|
||||
|
||||
- id: ui-plugins
|
||||
name: '@deepseek-ai/dsh-client-ui-plugins'
|
||||
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
@@ -267,6 +274,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
|
||||
@@ -379,12 +389,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'
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plugins": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^",
|
||||
@@ -87,6 +88,7 @@
|
||||
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
|
||||
@@ -57,28 +57,24 @@ Examples:
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the parsed flags into the value injected rows read.
|
||||
* @param program - the parsed web command.
|
||||
* @returns this invocation's immutable Web options.
|
||||
*/
|
||||
function planWebStartup(program: Command): WebStartupValues {
|
||||
const options = program.opts<WebOptions>()
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
return {
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
trustedHosts: options.trustedHost ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and provide the Web invocation as an ordinary Cordis service.
|
||||
* Parse and provide the Web invocation as an ordinary Cordis service. The
|
||||
* command's action publishes the flags this invocation named; a non-numeric
|
||||
* `--port` is a usage error, so on rejection (and on `--help`) nothing is
|
||||
* provided.
|
||||
* @param ctx - plugin context carrying the command line.
|
||||
* @returns nothing once values are provided, or when the command requested exit.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values)
|
||||
const program = webCommand()
|
||||
program.action(() => {
|
||||
const options = program.opts<WebOptions>()
|
||||
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||
}
|
||||
ctx.provide(WEB_STARTUP_SERVICE, {
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
trustedHosts: options.trustedHost ?? [],
|
||||
} satisfies WebStartupValues)
|
||||
})
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
|
||||
README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca
|
||||
README.md: 236531281c17ef982982e97caad99491584bd0b5
|
||||
README.zh.md: e619ffaa6341f509537342bde90344141d4c8f64
|
||||
|
||||
@@ -41,6 +41,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. |
|
||||
|
||||
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 |
|
||||
|
||||
每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
|
||||
|
||||
|
||||
@@ -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: ce8117fc4c95071a6db8592302030a8a63b5478b
|
||||
README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446
|
||||
README.zh.md: 7c5a70ef5d032fab8d3b75e84de6608b43f2e294
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-models/README.md
|
||||
README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff
|
||||
README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de
|
||||
README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b
|
||||
README.zh.md: 63fb1b486acc2bca34792f485ffd89fb32749e43
|
||||
|
||||
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek,即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Official-DeepSeek first-run step. Readiness comes from the same
|
||||
* provider/settings/credential join as the Models page; the prompt only
|
||||
* routes the user to that page's single credential editor.
|
||||
* provider/settings/credential join as the Models page: any provider the user
|
||||
* can already talk to ends the step, and only a user with none is offered the
|
||||
* official DeepSeek route. The prompt itself only routes to that page's single
|
||||
* credential editor.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
@@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import { deepSeekReadiness } from './store.ts'
|
||||
import { onboardingReadiness } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './DeepSeekOnboardingDialog.module.css'
|
||||
|
||||
@@ -34,15 +36,15 @@ function assertNever(_value: never): never {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt a first-run user to open Models while the official adapter exists
|
||||
* and its effective credential is not configured.
|
||||
* Prompt a first-run user to open Models while no provider can serve requests
|
||||
* and the official adapter exists with an unconfigured effective credential.
|
||||
* @param props - settings-shell owner state and Models feature dependencies.
|
||||
* @returns the onboarding page or null when onboarding needs no intervention.
|
||||
*/
|
||||
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
|
||||
const { complete, openSection, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const readiness = deepSeekReadiness(state)
|
||||
const readiness = onboardingReadiness(state)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
useEffect(() => {
|
||||
if (
|
||||
readiness.kind === 'adapter-absent'
|
||||
|| readiness.kind === 'configured'
|
||||
|| readiness.kind === 'provider-ready'
|
||||
|| readiness.kind === 'unavailable'
|
||||
) complete()
|
||||
}, [complete, readiness.kind])
|
||||
@@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
case 'configured':
|
||||
case 'provider-ready':
|
||||
case 'unavailable':
|
||||
return null
|
||||
case 'credential-missing':
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time. Rows expose only confirmed API-key state through accessible
|
||||
* solid configured or missing dots. A whole-section provider without a
|
||||
* configured key (the unconfigured DeepSeek posture) renders as its open setup
|
||||
* card instead of a row; the add flow is a card carrying the dormant-provider
|
||||
* select. Every mutation writes through the wire, while a provider removal first requires
|
||||
* confirmation; the page re-renders from pushed invalidations or the
|
||||
* post-apply reload.
|
||||
* configured key renders as its open setup card instead of a row, but only in
|
||||
* the first-run posture — no provider on the page can serve requests yet — and
|
||||
* only until the user closes that card; the add flow is a card carrying the
|
||||
* dormant-provider select. Each card kind owns its own open state, so closing
|
||||
* one never discards a draft in another. Every mutation writes through the
|
||||
* wire, while a provider removal first requires confirmation; the page
|
||||
* re-renders from pushed invalidations or the post-apply reload.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
@@ -116,11 +118,15 @@ export async function removeProviderProfile(
|
||||
|
||||
/**
|
||||
* Whether a whole-section provider still needs its first key: an unconfigured
|
||||
* credential opens the setup card instead of showing a row.
|
||||
* credential opens the setup card instead of showing a row. This is the
|
||||
* first-run posture alone — a user who can already reach some provider gets an
|
||||
* ordinary row with the missing-key dot, since nothing here is blocking them.
|
||||
* @param row - the joined provider row.
|
||||
* @param anyUsable - whether any joined row can already serve requests.
|
||||
* @returns whether to render the setup card.
|
||||
*/
|
||||
export function needsSetup(row: ProviderRow): boolean {
|
||||
export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
|
||||
if (anyUsable) return false
|
||||
if (row.entry.settingsPath.length > 0) return false
|
||||
return row.credential?.configured !== true
|
||||
}
|
||||
@@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
const [dismissedSetup, setDismissedSetup] = useState<ReadonlySet<string>>(() => new Set())
|
||||
|
||||
const announceSaved = (target: ProviderIdentity): void => {
|
||||
// Announced only once the refreshed directory is in the snapshot the
|
||||
// notice reads its name from: an apply can rename the route, and the
|
||||
// target captured when the card opened still carries the old name.
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) {
|
||||
// Announced only once the refreshed directory is in the snapshot the
|
||||
// notice reads its name from: an apply can rename the route, and the
|
||||
// target captured when the card opened still carries the old name.
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
if (changed) announceSaved(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a setup card, which owns none of the state above: the row-editor,
|
||||
* add, and declare cards each own one of those, so clearing them here would
|
||||
* discard a draft the user opened beside this card. Dismissal is this card's
|
||||
* own — the provider falls back to an ordinary row for the rest of the
|
||||
* session, and reopens through Edit.
|
||||
*/
|
||||
const closeSetup = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setDismissedSetup(previous => new Set([...previous, target.provider]))
|
||||
if (changed) announceSaved(target)
|
||||
}
|
||||
|
||||
const closeDelete = (): void => {
|
||||
@@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
? savedTarget
|
||||
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
|
||||
|
||||
// One fact decides both first-run postures on this page and the onboarding
|
||||
// step: whether the user already has a provider to talk to.
|
||||
const anyUsable = state.rows.some(providerUsable)
|
||||
const configured = state.rows.filter(row => row.configured)
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
@@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const namespace = state.namespaces.get(target.settingsNs)
|
||||
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
|
||||
if (namespace === undefined) return null
|
||||
if (needsSetup(row)) {
|
||||
if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
|
||||
// First-run posture: the provider exists but has no key — the
|
||||
// setup card IS its presence on the page.
|
||||
// setup card IS its presence on the page, until the user closes it.
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['setupCard']}>
|
||||
{renderProviderEditor({
|
||||
@@ -276,7 +300,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
onClose: (changed) => { closeSetup(changed, target) },
|
||||
})}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -189,32 +189,49 @@ export class ModelsSettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** DeepSeek onboarding readiness derived only from the shared Models join. */
|
||||
export type DeepSeekReadiness =
|
||||
/**
|
||||
* Whether a joined row can serve model requests as it stands: the route is
|
||||
* registered with the adapter registry, and whatever credential its resolved
|
||||
* profile names is stored. A profile naming no reference authenticates through
|
||||
* the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs
|
||||
* nothing), as does a live route with no settings address at all, so neither
|
||||
* owes this page a key.
|
||||
* @param row - one joined provider row.
|
||||
* @returns whether the user already has this provider to talk to.
|
||||
*/
|
||||
export function providerUsable(row: ProviderRow): boolean {
|
||||
if (!row.entry.active) return false
|
||||
if (row.apiKeyEnv === undefined) return true
|
||||
return row.credential?.configured === true
|
||||
}
|
||||
|
||||
/** First-run onboarding readiness derived only from the shared Models join. */
|
||||
export type OnboardingReadiness =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'adapter-absent' }
|
||||
| { kind: 'configured' }
|
||||
| { kind: 'provider-ready' }
|
||||
| { kind: 'credential-missing' }
|
||||
| {
|
||||
kind: 'unavailable'
|
||||
reason:
|
||||
| 'load-failed'
|
||||
| 'provider-inactive'
|
||||
| 'settings-unavailable'
|
||||
| 'credential-ref-unavailable'
|
||||
| 'credentials-unavailable'
|
||||
| 'settings-read-only'
|
||||
| 'credential-read-only'
|
||||
}
|
||||
|
||||
/**
|
||||
* Project official-DeepSeek readiness from the provider/settings/credential
|
||||
* join used by the Models page. A missing official configurable-provider
|
||||
* Project first-run readiness from the provider/settings/credential join used
|
||||
* by the Models page. The step exists to leave the user with a model to talk
|
||||
* to, so ANY usable provider ends it; only when none exists does the official
|
||||
* DeepSeek route — the one route the prompt can offer a key field for — decide
|
||||
* whether prompting can help. A missing official configurable-provider
|
||||
* declaration means the adapter is not repairable by navigating to Models.
|
||||
* @param state - current shared Models join snapshot.
|
||||
* @returns the onboarding state without reading a parallel fact source.
|
||||
*/
|
||||
export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness {
|
||||
export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness {
|
||||
if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
|
||||
return { kind: 'loading' }
|
||||
}
|
||||
@@ -224,6 +241,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
|
||||
reason: 'load-failed',
|
||||
}
|
||||
}
|
||||
if (state.rows.some(providerUsable)) return { kind: 'provider-ready' }
|
||||
const row = state.rows.find(candidate =>
|
||||
candidate.entry.provider === 'deepseek-official'
|
||||
&& candidate.entry.settingsNs === 'llm-deepseek'
|
||||
@@ -235,33 +253,14 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
|
||||
reason: 'provider-inactive',
|
||||
}
|
||||
}
|
||||
if (!row.configured) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.apiKeyEnv === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credential-ref-unavailable',
|
||||
}
|
||||
}
|
||||
if (state.credentialError !== null) {
|
||||
// Past the usable gate an active route names a reference it has no stored
|
||||
// credential for, so the remaining questions are all about that credential.
|
||||
if (state.credentialError !== null || row.credential === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.credential === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.credential.configured) {
|
||||
return { kind: 'configured' }
|
||||
}
|
||||
if (!state.writable) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
|
||||
@@ -23,6 +23,8 @@ afterEach(cleanup)
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
|
||||
const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
|
||||
const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' }
|
||||
const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET)
|
||||
|
||||
/** Open one row's capacity disclosure (1-based, as the labels read). */
|
||||
function expandRow(position: number): void {
|
||||
@@ -181,8 +183,8 @@ function scriptedFace(overrides: {
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
|
||||
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
||||
const { face, update, replace, mutate, set, unset } = scripted
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
@@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
return { view, face, update, replace, mutate, set, unset, controller }
|
||||
}
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
return mountFace(scriptedFace(overrides))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount for a user who cannot reach any provider yet: no credential is stored
|
||||
* anywhere, so the whole-section DeepSeek route owns the first-run setup card.
|
||||
*/
|
||||
async function mountFirstRun(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const scripted = scriptedFace(overrides)
|
||||
scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) =>
|
||||
Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
||||
})))
|
||||
return mountFace(scripted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount and open the DeepSeek editor. The shared fixture already has a usable
|
||||
* openai route, so DeepSeek is an ordinary row whose card opens through Edit
|
||||
* rather than by itself.
|
||||
*/
|
||||
async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const mounted = await mountSection(overrides)
|
||||
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
|
||||
return mounted
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('renders nothing before the slot injects its dependencies', () => {
|
||||
const uninjected = {} as ModelsSectionProps
|
||||
@@ -202,20 +232,32 @@ describe('ModelsSection', () => {
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
|
||||
await mountSection()
|
||||
// DeepSeek has no configured credential and no stored apiKey → setup card.
|
||||
it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
|
||||
await mountFirstRun()
|
||||
// Nothing is reachable yet, and DeepSeek has no configured credential and
|
||||
// no stored apiKey → setup card.
|
||||
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('Active')).toBeNull()
|
||||
expect(screen.queryByText('Inactive')).toBeNull()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leaves the unkeyed provider a plain row once another provider is usable', async () => {
|
||||
await mountSection()
|
||||
// openai's key is stored, so the user is not blocked and nothing on the
|
||||
// page opens itself over them.
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
const configured = screen.getByRole('img', { name: en.credentialConfigured })
|
||||
expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
|
||||
expect(configured.className).toContain('credentialDotConfigured')
|
||||
expect(configured.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
||||
expect(missing.closest('li')?.textContent).toContain('DeepSeek')
|
||||
// The card is still one click away.
|
||||
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
|
||||
@@ -241,7 +283,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('turns the setup card into a row once the credential reports configured', async () => {
|
||||
const { face } = await mountSection()
|
||||
const { face } = await mountFirstRun()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
|
||||
})))
|
||||
@@ -259,7 +301,7 @@ describe('ModelsSection', () => {
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('decides setup need from the joined credential state', () => {
|
||||
it('decides setup need from the joined credential state and the first-run posture', () => {
|
||||
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
||||
const row = (credential: ProviderRow['credential']): ProviderRow => ({
|
||||
entry,
|
||||
@@ -268,10 +310,13 @@ describe('ModelsSection', () => {
|
||||
apiKeyEnv: 'X',
|
||||
credential,
|
||||
})
|
||||
expect(needsSetup(row(undefined))).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
|
||||
expect(needsSetup(row(undefined), false)).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false)
|
||||
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
||||
expect(needsSetup(nested)).toBe(false)
|
||||
expect(needsSetup(nested, false)).toBe(false)
|
||||
// A user who can already reach some provider is not in the first-run
|
||||
// posture, so nothing on the page opens itself.
|
||||
expect(needsSetup(row(undefined), true)).toBe(false)
|
||||
})
|
||||
|
||||
it('derives conventional credential references from route ids', () => {
|
||||
@@ -296,7 +341,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
||||
const { set, update, face } = await mountSection()
|
||||
const { set, update, face } = await mountFirstRun()
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: ' sk-live ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
@@ -311,7 +356,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('applies customized deepseek fields as path ops', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -332,7 +377,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -366,7 +411,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('rejects duplicate DeepSeek model ids before writing', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
const { mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.click(screen.getByText(en.addModel))
|
||||
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
||||
@@ -436,7 +481,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('accepts a suffixed context window and stores the plain count', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -476,7 +521,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('keeps unreadable context-window text on screen and refuses the write', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
const { mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
@@ -539,7 +584,7 @@ describe('ModelsSection', () => {
|
||||
// The regression: one active buffer meant editing a second row displaced
|
||||
// the first, which then fell back to rendering its stored NaN as `NaN` —
|
||||
// losing the text the user was told they could still correct.
|
||||
await mountSection()
|
||||
await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
@@ -553,7 +598,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('re-keys the typed text around a removed row', async () => {
|
||||
await mountSection()
|
||||
await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
|
||||
const removeRow = (at: number): void => {
|
||||
@@ -587,7 +632,7 @@ describe('ModelsSection', () => {
|
||||
// The regression: reset removed the override but left the buffer, so an
|
||||
// inherited row displayed text no settings layer stores — and because an
|
||||
// unreadable buffer never settles, it stayed there indefinitely.
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -605,12 +650,12 @@ describe('ModelsSection', () => {
|
||||
// Reset put the draft back where it started, so Apply writes nothing at
|
||||
// all rather than persisting whatever the stale text had parsed to.
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
|
||||
await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('edits an output cap per model and carries its text across a removal', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -644,7 +689,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('settles a pasted id and refuses whitespace that would never match', async () => {
|
||||
await mountSection()
|
||||
await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
|
||||
fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
|
||||
@@ -681,7 +726,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -715,7 +760,7 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
||||
// A whole-section replace would clobber sibling overrides to clear one field.
|
||||
const { replace, update, mutate } = await mountSection()
|
||||
const { replace, update, mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(url.value).toBe('https://base')
|
||||
@@ -762,7 +807,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('rejects an invalid draft before writing', async () => {
|
||||
const { update } = await mountSection()
|
||||
const { update } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
@@ -772,19 +817,17 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('edits a pi-ai profile with the curated fields only', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
// The configured credential shows as the stored placeholder.
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
|
||||
// pi-ai carries Base URL too: the stored override shows as the value and
|
||||
// the effective profile endpoint as its placeholder source.
|
||||
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(urls).toHaveLength(2)
|
||||
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
|
||||
fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(url.value).toBe('https://proxy')
|
||||
fireEvent.change(url, { target: { value: 'https://proxy/v2' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the edited field travels: apiKeyEnv and headers were already stored
|
||||
// with these values, so no op restates them.
|
||||
@@ -803,14 +846,12 @@ describe('ModelsSection', () => {
|
||||
expect(pick.value).toBe('anthropic')
|
||||
// A dormant profile has no endpoint anywhere: the pi-ai placeholder
|
||||
// falls back to the provider-default wording.
|
||||
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const addKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder).toBe(en.baseUrlDefault)
|
||||
const addKey = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -824,7 +865,7 @@ describe('ModelsSection', () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -855,9 +896,8 @@ describe('ModelsSection', () => {
|
||||
const { face, controller } = await mountSection({ mutate, set })
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText('credential store unavailable')
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
face.settings.describe.mockResolvedValue(ok({
|
||||
@@ -867,7 +907,7 @@ describe('ModelsSection', () => {
|
||||
}))
|
||||
await act(async () => { await controller.load() })
|
||||
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
|
||||
@@ -883,10 +923,9 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
|
||||
})
|
||||
// The hint-only card cannot apply anything.
|
||||
const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
|
||||
expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
// The hint-only card cannot apply anything, and offers no key field.
|
||||
expect(screen.getByText<HTMLButtonElement>(en.apply).disabled).toBe(true)
|
||||
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces a rejected settings write and never stores the key after it', async () => {
|
||||
@@ -895,9 +934,8 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-x' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(/unknown pi-ai provider/)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -930,7 +968,7 @@ describe('ModelsSection', () => {
|
||||
it('tells the user to reopen when another writer moved the namespace first', async () => {
|
||||
// The stale-draft overwrite: two tabs open the same card, the other saves,
|
||||
// and this one must be refused rather than replay its opening snapshot.
|
||||
const { set } = await mountSection({
|
||||
const { set } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -944,7 +982,7 @@ describe('ModelsSection', () => {
|
||||
// A transport failure (disconnect, or the 403 a non-loopback browser now
|
||||
// gets on the whole configuration plane) rejects rather than returning a
|
||||
// failed envelope: without a catch the card would stay busy forever.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
|
||||
await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
@@ -954,7 +992,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('surfaces a shadowed credential write on the card', async () => {
|
||||
await mountSection({
|
||||
await mountFirstRun({
|
||||
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
|
||||
})
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
@@ -971,9 +1009,8 @@ describe('ModelsSection', () => {
|
||||
configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
|
||||
}])),
|
||||
})))
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
|
||||
expect(editorKey.disabled).toBe(true)
|
||||
})
|
||||
@@ -981,12 +1018,11 @@ describe('ModelsSection', () => {
|
||||
it('keeps a failed credential describe silent and the input usable', async () => {
|
||||
const { face, set } = await mountSection()
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
@@ -1085,15 +1121,15 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('toggles the row editor closed on a second edit click and on cancel', async () => {
|
||||
const { update } = await mountSection()
|
||||
const edit = screen.getAllByText(en.edit)[0] as HTMLElement
|
||||
const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
|
||||
fireEvent.click(edit)
|
||||
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
|
||||
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
|
||||
fireEvent.click(edit)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
|
||||
fireEvent.click(edit)
|
||||
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1101,11 +1137,34 @@ describe('ModelsSection', () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
await screen.findByText(en.add)
|
||||
expect(screen.queryByLabelText(en.provider)).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses the setup card on cancel without disturbing another open card', async () => {
|
||||
// The regression: the setup card shared the row/add/declare close handler,
|
||||
// so cancelling it discarded the add card's draft while staying open itself.
|
||||
await mountFirstRun()
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2)
|
||||
|
||||
// The setup card is the first one on the page, above the add block.
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement)
|
||||
// The add card kept its draft…
|
||||
expect(screen.getByLabelText(en.provider)).toBeTruthy()
|
||||
// …and DeepSeek collapsed to an ordinary row carrying the missing-key dot.
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
expect(screen.getAllByRole('img', { name: en.credentialMissing })
|
||||
.some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true)
|
||||
// Its card reopens through Edit, which closes the add card as any row does.
|
||||
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
expect(screen.queryByLabelText(en.provider)).toBeNull()
|
||||
})
|
||||
|
||||
it('loads on first render of an idle controller', async () => {
|
||||
const { face } = scriptedFace()
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Pure official-DeepSeek readiness projection over the shared Models join. */
|
||||
/** Pure first-run readiness projection over the shared Models join. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
|
||||
import { deepSeekReadiness } from '../src/client/store.ts'
|
||||
import { onboardingReadiness, providerUsable } from '../src/client/store.ts'
|
||||
|
||||
const missingCredential: CredentialView = { configured: false, writable: true }
|
||||
|
||||
@@ -23,6 +23,24 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
}
|
||||
}
|
||||
|
||||
/** A second provider the user configured themselves. */
|
||||
function otherRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
return {
|
||||
entry: {
|
||||
provider: 'hfai',
|
||||
displayName: 'HFAI',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'hfai'],
|
||||
active: true,
|
||||
},
|
||||
configured: true,
|
||||
removable: true,
|
||||
apiKeyEnv: 'HFAI_API_KEY',
|
||||
credential: { configured: true, source: 'file', writable: true },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
|
||||
return {
|
||||
status: 'ready',
|
||||
@@ -35,12 +53,25 @@ function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsStat
|
||||
}
|
||||
}
|
||||
|
||||
describe('deepSeekReadiness', () => {
|
||||
describe('providerUsable', () => {
|
||||
it('requires a registered route and a stored key for every named reference', () => {
|
||||
expect(providerUsable(otherRow())).toBe(true)
|
||||
expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false)
|
||||
expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false)
|
||||
expect(providerUsable(otherRow({ credential: undefined }))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a reference-free registered route as provider-native authentication', () => {
|
||||
expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboardingReadiness', () => {
|
||||
it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({
|
||||
entry: {
|
||||
...row().entry,
|
||||
@@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => {
|
||||
})
|
||||
|
||||
it('reports a missing writable effective credential', () => {
|
||||
expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
|
||||
expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' })
|
||||
})
|
||||
|
||||
it('ends onboarding once any other registered provider can serve requests', () => {
|
||||
expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' })
|
||||
// A provider the user cannot reach yet leaves the prompt in place.
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row(), otherRow({ credential: missingCredential })],
|
||||
}))).toEqual({ kind: 'credential-missing' })
|
||||
})
|
||||
|
||||
it('accepts file and process-environment credentials without prompting', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
expect(deepSeekReadiness(state({
|
||||
}))).toEqual({ kind: 'provider-ready' })
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
}))).toEqual({ kind: 'provider-ready' })
|
||||
})
|
||||
|
||||
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
it('turns missing capabilities into diagnostics that never block the product', () => {
|
||||
expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'load-failed',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ entry: { ...row().entry, active: false } })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ configured: false })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ apiKeyEnv: undefined })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
credentialError: 'credentials service is absent',
|
||||
}))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: undefined })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: { configured: false, writable: false } })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
|
||||
expect(deepSeekReadiness(state({ writable: false }))).toEqual({
|
||||
expect(onboardingReadiness(state({ writable: false }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-read-only',
|
||||
})
|
||||
|
||||
6
packages/client/ui-plugins/README.i18n.yaml
Normal file
6
packages/client/ui-plugins/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-plugins/README.md
|
||||
README.md: bb487d5e2cbd34406d83867997ede4d70b190d70
|
||||
README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9
|
||||
20
packages/client/ui-plugins/README.md
Normal file
20
packages/client/ui-plugins/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-ui-plugins
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
|
||||
|
||||
The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package only visualizes a Host-owned deployment snapshot in browser Settings and registers nothing model-facing.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot.
|
||||
- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls.
|
||||
20
packages/client/ui-plugins/README.zh.md
Normal file
20
packages/client/ui-plugins/README.zh.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-ui-plugins
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。
|
||||
|
||||
页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为本包只在浏览器设置中展示 Host 拥有的部署快照,不注册任何模型接口。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。
|
||||
- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。
|
||||
80
packages/client/ui-plugins/package.json
Normal file
80
packages/client/ui-plugins/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugins",
|
||||
"description": "Read-only Cordis Loader plugin inventory in Web settings",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-plugins"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.heading h2,
|
||||
.catalogHeading h3,
|
||||
.status,
|
||||
.failure p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status,
|
||||
.failure {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.failure button {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.catalog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.search > svg {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 0 34px 0 36px;
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search input::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.search input:focus-visible {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.catalogHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.catalogHeading h3 {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.catalogHeading span {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.card[data-open='true'] {
|
||||
border-color: var(--dsw-alias-border-l1);
|
||||
box-shadow: var(--dsw-shadow-lv1);
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border: 0;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardContent:hover,
|
||||
.card[data-open='true'] > .cardContent {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.cardContent:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cardTrailing {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.statusDot[data-phase='active'] {
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.statusDot[data-phase='failed'] {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.statusDot[data-phase='loading'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.configTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configTag[data-enabled='true'] {
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.card[data-open='true'] .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.cardDetails {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding: 10px 14px 12px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.entryValue {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 6px 10px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.details div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.details dt {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.details dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.chevron {
|
||||
transition: transform 140ms var(--ds-ease-in-out);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.cards {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
195
packages/client/ui-plugins/src/client/PluginSettingsSection.tsx
Normal file
195
packages/client/ui-plugins/src/client/PluginSettingsSection.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
IconChevronDownOutline14,
|
||||
IconSearchOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PluginsKey } from './locales.ts'
|
||||
import css from './PluginSettingsSection.module.css'
|
||||
|
||||
/** Registration-side Remote face used by the section. */
|
||||
export interface PluginSettingsSectionInjected {
|
||||
/** Read a current Host inventory snapshot. */
|
||||
list: () => Promise<PluginInventorySnapshot>
|
||||
}
|
||||
|
||||
type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
|
||||
type PluginFiberPhase = PluginInventoryEntry['fiberPhase']
|
||||
|
||||
/** Full component props assembled by the Settings slot renderer. */
|
||||
export type PluginSettingsSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& InjectFace<PluginSettingsSectionInjected>
|
||||
|
||||
type ViewState =
|
||||
| { readonly status: 'loading' }
|
||||
| { readonly status: 'error' }
|
||||
| { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot }
|
||||
|
||||
const PHASE_KEYS = {
|
||||
pending: 'pending',
|
||||
loading: 'loadingPhase',
|
||||
active: 'active',
|
||||
failed: 'failed',
|
||||
unloading: 'unloading',
|
||||
} satisfies Record<Exclude<PluginFiberPhase, null>, PluginsKey>
|
||||
|
||||
/** Localized accessible label for one root Fiber phase. */
|
||||
function phaseLabel(
|
||||
phase: PluginFiberPhase,
|
||||
t: PluginSettingsSectionProps['t'],
|
||||
): string {
|
||||
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
|
||||
}
|
||||
|
||||
/** Compact a module specifier without guessing whether its Loader id was generated. */
|
||||
function moduleShortName(moduleName: string): string {
|
||||
const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName
|
||||
return unscoped
|
||||
.replace(/^cordis:/, '')
|
||||
.replace(/^cordis-plugin-/, '')
|
||||
.replace(/^dsh-(?:host-|client-)?/, '')
|
||||
}
|
||||
|
||||
/** Whether an inventory row matches the local catalog query. */
|
||||
function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean {
|
||||
if (normalizedQuery.length === 0) return true
|
||||
return [entry.moduleName, entry.entryId]
|
||||
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
|
||||
}
|
||||
|
||||
/** Render the read-only current Loader inventory. */
|
||||
export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode {
|
||||
const titleId = useId()
|
||||
const [request, setRequest] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
|
||||
const [state, setState] = useState<ViewState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
void Promise.resolve().then(() => list()).then(
|
||||
(snapshot) => { if (current) setState({ status: 'ready', snapshot }) },
|
||||
() => { if (current) setState({ status: 'error' }) },
|
||||
)
|
||||
return () => { current = false }
|
||||
}, [list, request])
|
||||
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
const filteredEntries = useMemo(
|
||||
() => state.status === 'ready'
|
||||
? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery))
|
||||
: [],
|
||||
[normalizedQuery, state],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) {
|
||||
setExpanded(null)
|
||||
}
|
||||
}, [expanded, filteredEntries])
|
||||
|
||||
const retry = (): void => {
|
||||
setState({ status: 'loading' })
|
||||
setRequest(value => value + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.section} aria-labelledby={titleId} aria-busy={state.status === 'loading'}>
|
||||
<header className={css.heading}>
|
||||
<h2 id={titleId}>{t('title')}</h2>
|
||||
</header>
|
||||
{state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
|
||||
{state.status === 'error' ? (
|
||||
<div className={css.failure}>
|
||||
<p role="alert">{t('error')}</p>
|
||||
<button type="button" onClick={retry}>{t('retry')}</button>
|
||||
</div>
|
||||
) : null}
|
||||
{state.status === 'ready' ? (
|
||||
<div className={css.catalog}>
|
||||
<label className={css.search}>
|
||||
<IconSearchOutline16 aria-hidden="true" />
|
||||
<span className={css.visuallyHidden}>{t('search')}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('search')}
|
||||
aria-label={t('search')}
|
||||
onChange={(event) => { setQuery(event.currentTarget.value) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.catalogHeading}>
|
||||
<h3>{t('catalog')}</h3>
|
||||
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
|
||||
</div>
|
||||
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
|
||||
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
|
||||
? <p className={css.status}>{t('emptySearch')}</p>
|
||||
: null}
|
||||
{filteredEntries.length > 0 ? (
|
||||
<ul className={css.cards}>
|
||||
{filteredEntries.map((entry) => {
|
||||
const status = phaseLabel(entry.fiberPhase, t)
|
||||
const title = moduleShortName(entry.moduleName)
|
||||
const open = expanded === entry.entryId
|
||||
const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}`
|
||||
return (
|
||||
<li
|
||||
className={css.card}
|
||||
key={entry.entryId}
|
||||
data-plugin-entry={entry.entryId}
|
||||
data-open={open ? 'true' : undefined}
|
||||
>
|
||||
<button
|
||||
className={css.cardContent}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={detailId}
|
||||
aria-label={`${title}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`}
|
||||
onClick={() => {
|
||||
setExpanded(current => current === entry.entryId ? null : entry.entryId)
|
||||
}}
|
||||
>
|
||||
<strong className={css.cardTitle} title={entry.moduleName}>{title}</strong>
|
||||
<span className={css.cardTrailing}>
|
||||
<span
|
||||
className={css.statusDot}
|
||||
data-phase={entry.fiberPhase ?? 'unobserved'}
|
||||
role="img"
|
||||
aria-label={status}
|
||||
title={status}
|
||||
/>
|
||||
<span className={css.configTag} data-enabled={entry.enabled ? 'true' : 'false'}>
|
||||
{t(entry.enabled ? 'enabledTag' : 'disabledTag')}
|
||||
</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className={css.cardDetails} id={detailId}>
|
||||
<code className={css.entryValue} data-loader-entry>{entry.entryId}</code>
|
||||
<dl className={css.details}>
|
||||
<div>
|
||||
<dt>{t('configuration')}</dt>
|
||||
<dd>{t(entry.enabled ? 'enabledTag' : 'disabledTag')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('cordis')}</dt>
|
||||
<dd>{status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
47
packages/client/ui-plugins/src/client/index.ts
Normal file
47
packages/client/ui-plugins/src/client/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/** Read-only Host plugin inventory registered into Web Settings. */
|
||||
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx'
|
||||
import { en, zh, type PluginsKey } from './locales.ts'
|
||||
|
||||
export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx'
|
||||
export type { PluginsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Read-only Host plugin inventory copy. */
|
||||
'settings.plugins': PluginsKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'settings.plugins'
|
||||
|
||||
/** Services required by the Settings registration and generated Remote face. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
|
||||
|
||||
/** Register the lazy plugin inventory page below Models in Settings. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries')
|
||||
|
||||
const t = ctx.locale.bind(NS)
|
||||
const list: PluginSettingsSectionInjected['list'] = async () => {
|
||||
const result = await ctx.remote.pluginInventory.list()
|
||||
if (!result.ok) {
|
||||
throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
const injected = (): PluginSettingsSectionInjected => ({ list })
|
||||
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'plugin-inventory',
|
||||
order: 15,
|
||||
label: () => t('nav'),
|
||||
locale: NS,
|
||||
inject: injected,
|
||||
}, PluginSettingsSection))
|
||||
}
|
||||
50
packages/client/ui-plugins/src/client/locales.ts
Normal file
50
packages/client/ui-plugins/src/client/locales.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** Copy dictionaries for the plugin inventory Settings section. */
|
||||
|
||||
/** Simplified Chinese dictionary and key source of truth. */
|
||||
export const zh = {
|
||||
nav: '插件',
|
||||
title: '插件',
|
||||
loading: '正在读取插件…',
|
||||
error: '暂时无法读取插件。',
|
||||
retry: '重试',
|
||||
search: '搜索插件',
|
||||
catalog: '插件列表',
|
||||
empty: '暂无插件。',
|
||||
emptySearch: '没有匹配的插件。',
|
||||
enabledTag: '已启用',
|
||||
disabledTag: '已停用',
|
||||
configuration: '配置状态',
|
||||
cordis: 'Cordis 状态',
|
||||
unobserved: '未挂载',
|
||||
pending: '等待依赖',
|
||||
loadingPhase: '加载中',
|
||||
active: '已挂载',
|
||||
failed: '挂载失败',
|
||||
unloading: '卸载中',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Plugin inventory locale key union. */
|
||||
export type PluginsKey = keyof typeof zh
|
||||
|
||||
/** English dictionary checked against the Chinese key set. */
|
||||
export const en = {
|
||||
nav: 'Plugins',
|
||||
title: 'Plugins',
|
||||
loading: 'Reading plugins…',
|
||||
error: 'Plugins are temporarily unavailable.',
|
||||
retry: 'Retry',
|
||||
search: 'Search plugins',
|
||||
catalog: 'Plugin list',
|
||||
empty: 'No plugins are available.',
|
||||
emptySearch: 'No matching plugins.',
|
||||
enabledTag: 'Enabled',
|
||||
disabledTag: 'Disabled',
|
||||
configuration: 'Configuration',
|
||||
cordis: 'Cordis status',
|
||||
unobserved: 'Not mounted',
|
||||
pending: 'Waiting for dependencies',
|
||||
loadingPhase: 'Loading',
|
||||
active: 'Mounted',
|
||||
failed: 'Mount failed',
|
||||
unloading: 'Unloading',
|
||||
} satisfies Record<PluginsKey, string>
|
||||
6
packages/client/ui-plugins/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-plugins/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
4
packages/client/ui-plugins/src/index.ts
Normal file
4
packages/client/ui-plugins/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the plugin settings section. */
|
||||
export function apply(): void {}
|
||||
20
packages/client/ui-plugins/src/invariant.ts
Normal file
20
packages/client/ui-plugins/src/invariant.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugins'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-plugins-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this package owns a read-only Settings contribution. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, NS } from '../src/client/index.ts'
|
||||
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
|
||||
import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx'
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
afterEach(cleanup)
|
||||
|
||||
const EMPTY = { entries: [] }
|
||||
type ListResult =
|
||||
| { readonly ok: true; readonly value: typeof EMPTY }
|
||||
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
class RemoteService extends Service {
|
||||
constructor(serviceCtx: Context) {
|
||||
super(serviceCtx, 'remote')
|
||||
}
|
||||
}
|
||||
new RemoteService(ctx)
|
||||
const list = vi.fn<() => Promise<ListResult>>()
|
||||
.mockResolvedValue({ ok: true, value: EMPTY })
|
||||
ctx.provide('remote.pluginInventory', { list })
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, list }
|
||||
}
|
||||
|
||||
function declare(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-plugins browser plugin', () => {
|
||||
it('declares only the services used by the Settings Remote contribution', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
|
||||
})
|
||||
|
||||
it('registers a localized section without reading the Remote eagerly', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const entry = b.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(PluginSettingsSection)
|
||||
expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 })
|
||||
expect(entry.locale).toBe(NS)
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('插件')
|
||||
expect(b.list).not.toHaveBeenCalled()
|
||||
|
||||
const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)()
|
||||
await expect(injected.list()).resolves.toEqual(EMPTY)
|
||||
expect(b.list).toHaveBeenCalledOnce()
|
||||
b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
|
||||
await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable')
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('follows locale and recovers across late declaration and declarer reload', async () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
|
||||
const stop = declare(b.slots)
|
||||
await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) })
|
||||
b.locale.setLocale('en')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins')
|
||||
|
||||
stop()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
declare(b.slots)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection)
|
||||
})
|
||||
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
128
packages/client/ui-plugins/tests/components.client.spec.tsx
Normal file
128
packages/client/ui-plugins/tests/components.client.spec.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
|
||||
import type {
|
||||
PluginSettingsSectionInjected,
|
||||
PluginSettingsSectionProps,
|
||||
} from '../src/client/PluginSettingsSection.tsx'
|
||||
import { en, type PluginsKey } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
type Snapshot = Awaited<ReturnType<PluginSettingsSectionInjected['list']>>
|
||||
const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t']
|
||||
const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never
|
||||
|
||||
function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps {
|
||||
return {
|
||||
close: vi.fn(),
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
t,
|
||||
list,
|
||||
}
|
||||
}
|
||||
|
||||
const SNAPSHOT = {
|
||||
entries: [
|
||||
{ entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
|
||||
{ entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' },
|
||||
{ entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' },
|
||||
{ entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' },
|
||||
{ entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' },
|
||||
{ entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null },
|
||||
],
|
||||
} as unknown as Snapshot
|
||||
|
||||
describe('PluginSettingsSection', () => {
|
||||
it('renders searchable two-column-card semantics with dots and tags', async () => {
|
||||
const deferred = Promise.withResolvers<Snapshot>()
|
||||
const list = vi.fn(() => deferred.promise)
|
||||
const view = render(<PluginSettingsSection {...props(list)} />)
|
||||
expect(screen.getByText(en.loading)).toBeTruthy()
|
||||
|
||||
await act(async () => { deferred.resolve(SNAPSHOT) })
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6')
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(6)
|
||||
expect(screen.getAllByText(en.enabledTag)).toHaveLength(5)
|
||||
expect(screen.getByText(en.disabledTag)).toBeTruthy()
|
||||
for (const value of [
|
||||
'Mounted',
|
||||
'Waiting for dependencies',
|
||||
'Loading',
|
||||
'Mount failed',
|
||||
'Unloading',
|
||||
'Not mounted',
|
||||
]) {
|
||||
expect(screen.getByRole('img', { name: value })).toBeTruthy()
|
||||
}
|
||||
const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' })
|
||||
expect(active.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(active)
|
||||
expect(active.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d')
|
||||
expect(screen.getByText(en.configuration)).toBeTruthy()
|
||||
expect(screen.getByText(en.cordis)).toBeTruthy()
|
||||
fireEvent.click(active)
|
||||
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
|
||||
|
||||
fireEvent.click(active)
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: en.search }), {
|
||||
target: { value: 'disabled-entry' },
|
||||
})
|
||||
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' }))
|
||||
expect(screen.getAllByText(en.disabledTag)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters by module name or Loader entry id', async () => {
|
||||
render(<PluginSettingsSection {...props(async () => SNAPSHOT)} />)
|
||||
const search = await screen.findByRole('searchbox', { name: en.search })
|
||||
|
||||
fireEvent.change(search, { target: { value: 'disabled-entry' } })
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.getByText('directory-picker-native')).toBeTruthy()
|
||||
|
||||
fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } })
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.getByText('hmr')).toBeTruthy()
|
||||
|
||||
fireEvent.change(search, { target: { value: 'not-a-plugin' } })
|
||||
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
|
||||
expect(screen.getByText(en.emptySearch)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a generic failure and retries into the empty state', async () => {
|
||||
const list = vi.fn<PluginSettingsSectionInjected['list']>()
|
||||
.mockRejectedValueOnce(new Error('private transport detail'))
|
||||
.mockResolvedValueOnce({ entries: [] })
|
||||
render(<PluginSettingsSection {...props(list)} />)
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
|
||||
expect(screen.queryByText('private transport detail')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.retry }))
|
||||
await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
|
||||
expect(await screen.findByText(en.empty)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
|
||||
const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list']
|
||||
const failed = render(<PluginSettingsSection {...props(syncFailure)} />)
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
|
||||
failed.unmount()
|
||||
|
||||
const deferred = Promise.withResolvers<Snapshot>()
|
||||
const pending = render(<PluginSettingsSection {...props(() => deferred.promise)} />)
|
||||
pending.unmount()
|
||||
await act(async () => { deferred.resolve(SNAPSHOT) })
|
||||
|
||||
const deferredFailure = Promise.withResolvers<Snapshot>()
|
||||
const pendingFailure = render(<PluginSettingsSection {...props(() => deferredFailure.promise)} />)
|
||||
pendingFailure.unmount()
|
||||
await act(async () => { deferredFailure.reject(new Error('late failure')) })
|
||||
})
|
||||
})
|
||||
15
packages/client/ui-plugins/tests/invariant.client.spec.ts
Normal file
15
packages/client/ui-plugins/tests/invariant.client.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as PluginsInvariant from '../src/invariant.ts'
|
||||
|
||||
describe('ui-plugins invariant companion', () => {
|
||||
it('registers the empty installer and keeps the node half inert', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined()
|
||||
const { apply } = await import('../src/index.ts')
|
||||
apply()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
36
packages/client/ui-plugins/tsconfig.json
Normal file
36
packages/client/ui-plugins/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-plugins/tsdown.config.ts
Normal file
3
packages/client/ui-plugins/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,18 @@ export const IconSearchOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_globe_outline_14 — meridian globe (harness-only figma extract). */
|
||||
export const IconGlobeOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.00018 0.353516C10.6708 0.353535 13.6468 3.32958 13.6469 7.00018C13.6468 10.6708 10.6708 13.6468 7.00018 13.6469C3.32957 13.6468 0.353535 10.6708 0.353516 7.00018C0.353535 3.32957 3.32957 0.353531 7.00018 0.353516ZM5.44643 7.59661C5.49463 8.97506 5.70762 10.191 6.02136 11.0793C6.20141 11.5891 6.40328 11.9585 6.59898 12.1889C6.79501 12.4196 6.93213 12.454 7.00018 12.454C7.06822 12.454 7.20533 12.4197 7.40138 12.1889C7.59708 11.9585 7.79895 11.589 7.979 11.0793C8.29274 10.191 8.50574 8.97506 8.55394 7.59661H5.44643ZM1.57861 7.59661C1.80785 9.70467 3.2386 11.4509 5.1715 12.1388C5.07135 11.9317 4.97972 11.7098 4.89746 11.477C4.53084 10.4391 4.30224 9.0828 4.25357 7.59661H1.57861ZM9.74679 7.59661C9.69813 9.0828 9.46952 10.4391 9.1029 11.477C9.0206 11.7099 8.92818 11.9316 8.82797 12.1388C10.7613 11.4511 12.1925 9.70496 12.4218 7.59661H9.74679ZM5.1706 1.8616C3.23814 2.54963 1.80876 4.29604 1.5795 6.40376H4.25357C4.30224 4.91756 4.53083 3.56129 4.89746 2.5234C4.97968 2.29066 5.07051 2.0686 5.1706 1.8616ZM7.00018 1.54637C6.93213 1.54638 6.79503 1.5807 6.59898 1.81145C6.40332 2.04177 6.20139 2.41058 6.02136 2.92012C5.70754 3.80851 5.49461 5.02499 5.44643 6.40376H8.55394C8.50575 5.025 8.29282 3.80851 7.979 2.92012C7.79898 2.41059 7.59705 2.04177 7.40138 1.81145C7.20531 1.58067 7.06823 1.54637 7.00018 1.54637ZM8.82887 1.8616C8.92902 2.0687 9.02064 2.29053 9.1029 2.5234C9.46953 3.56129 9.69812 4.91756 9.74679 6.40376H12.4209C12.1916 4.29575 10.7618 2.54943 8.82887 1.8616Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_settings_outline_14 */
|
||||
export const IconSettingsOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(68)
|
||||
it('exports the full icon set (46 deepsuite + 20 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(69)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,8 +23,13 @@ import { CONVERSATION_NS as NS } from '../../locale.ts'
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'>
|
||||
|
||||
const SEARCH_TITLES: Record<string, string> = {
|
||||
grep: 'Grep',
|
||||
glob: 'Glob',
|
||||
}
|
||||
|
||||
/**
|
||||
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
|
||||
* Search row: icon + Grep/Glob · {summary} in the shared ToolRow chrome, with the
|
||||
* completed search's card as the row's collapsed-by-default card body (a capped
|
||||
* search's recovery footer rides below it, inside ToolRow). Registered under
|
||||
* both `grep` and `glob`; the derived model's `kind` decides the card shape. A
|
||||
@@ -40,7 +45,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconSearchOutline16 size={14} />}
|
||||
title={model.title}
|
||||
title={SEARCH_TITLES[toolName] ?? model.title}
|
||||
// The result view's replacement title outranks the args-derived summary,
|
||||
// matching the terminal card's description precedence.
|
||||
summary={search?.title ?? model.summary}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// summary line alone.
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconBrowseOutline16, IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '../../contract/slots.ts'
|
||||
import { webCardModel } from '../models/web-card-model.ts'
|
||||
@@ -35,7 +35,8 @@ const WEB_TITLES: Record<string, string> = {
|
||||
export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const web = webCardModel(block)
|
||||
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
|
||||
// Web search uses a globe; local grep/glob keep the magnifier family.
|
||||
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconGlobeOutline14 size={14} />
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
|
||||
@@ -246,7 +246,8 @@ describe('SearchRow keyed card', () => {
|
||||
|
||||
it('collapses to the summary row; expanding reveals the grep card', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.getByText('Grep')).toBeTruthy()
|
||||
expect(view.queryByText('Search')).toBeNull()
|
||||
// Collapsed: the card is not in the DOM until the row is expanded.
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
expect(view.queryByText(/const foo = 1/)).toBeNull()
|
||||
@@ -259,6 +260,8 @@ describe('SearchRow keyed card', () => {
|
||||
|
||||
it('expands to the glob path card', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
|
||||
expect(view.getByText('Glob')).toBeTruthy()
|
||||
expect(view.queryByText('Search')).toBeNull()
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.getByText('src/a.ts')).toBeTruthy()
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { webCardModel } from '../src/client/tool/models/web-card-model.ts'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
|
||||
@@ -140,9 +141,11 @@ describe('chat row web body', () => {
|
||||
}
|
||||
|
||||
it('the WebRow collapses to the summary row, expanding to the full search card', () => {
|
||||
const globe = render(<IconGlobeOutline14 />).container.querySelector('svg')!.outerHTML
|
||||
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
|
||||
// Collapsed: the summary row alone, no card in the DOM.
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.container.querySelector('svg')?.outerHTML).toBe(globe)
|
||||
expect(view.queryByText('Titled')).toBeNull()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
toggleRow(view)
|
||||
|
||||
@@ -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-workflow-run/README.md
|
||||
README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5
|
||||
README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e
|
||||
README.md: 489715c51759b1efd2da68d3bd3e0f7788ce7ecd
|
||||
README.zh.md: 326a7ae4e4b8eaad43ca7ad0d22145452af6a734
|
||||
|
||||
@@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s
|
||||
|
||||
## Presentation and navigation
|
||||
|
||||
The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount.
|
||||
The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column.
|
||||
|
||||
A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## 展示与导航
|
||||
|
||||
运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。
|
||||
运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
|
||||
|
||||
只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runHeader:focus-visible {
|
||||
@@ -78,7 +77,6 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.phaseHeader:focus-visible {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState,
|
||||
DisclosureRow, IconChevronRightOutline14, StateDot,
|
||||
type DisclosureRowProps, type StateDotState,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -62,6 +63,36 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string {
|
||||
return t(count === 1 ? 'run.members.one' : 'run.members.other', { count })
|
||||
}
|
||||
|
||||
function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean {
|
||||
return phase.members.some(member => member.status !== 'completed')
|
||||
}
|
||||
|
||||
type StatusDisclosureProps = Omit<DisclosureRowProps, 'open' | 'expandable' | 'onToggle'>
|
||||
|
||||
/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */
|
||||
const forcedOpenToggle = (): void => {}
|
||||
|
||||
function ManualDisclosure(props: StatusDisclosureProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<DisclosureRow
|
||||
{...props}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & {
|
||||
/** Remount a clean Phase when its append-only member count changes between batched renders. */
|
||||
readonly cleanCycleKey?: number | undefined
|
||||
readonly requiresExpansion: boolean
|
||||
}) {
|
||||
if (!requiresExpansion) return <ManualDisclosure key={cleanCycleKey} {...props} />
|
||||
return <DisclosureRow {...props} open expandable={false} onToggle={forcedOpenToggle} />
|
||||
}
|
||||
|
||||
function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
|
||||
const counts = new Map<WorkflowRunStatus, number>()
|
||||
for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
|
||||
@@ -97,21 +128,19 @@ function navigableMembers(
|
||||
return result
|
||||
}
|
||||
|
||||
function RunHeader({ count, name, onToggle, open, status, t }: {
|
||||
function RunHeader({ children, count, name, requiresExpansion, status, t }: {
|
||||
readonly children: ReactNode
|
||||
readonly count: number
|
||||
readonly name: string
|
||||
readonly onToggle: () => void
|
||||
readonly open: boolean
|
||||
readonly requiresExpansion: boolean
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
return (
|
||||
<DisclosureRow
|
||||
<StatusDisclosure
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={t('run.title', { name })}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={onToggle}
|
||||
requiresExpansion={requiresExpansion}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
@@ -128,7 +157,9 @@ function RunHeader({ count, name, onToggle, open, status, t }: {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</StatusDisclosure>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,15 +199,12 @@ function PhaseSection({ phase, navigable, openSession, t }: {
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const toggle = (): void => { setOpen(value => !value) }
|
||||
return (
|
||||
<DisclosureRow
|
||||
<StatusDisclosure
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={readablePhase(phase.phase, t)}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={toggle}
|
||||
cleanCycleKey={phase.members.length}
|
||||
requiresExpansion={phaseRequiresExpansion(phase)}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
@@ -203,14 +231,15 @@ function PhaseSection({ phase, navigable, openSession, t }: {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</StatusDisclosure>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render one durable workflow run with independent run and phase disclosure. */
|
||||
/** Render one durable workflow run with status-driven run and phase disclosure. */
|
||||
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
|
||||
const [open, setOpen] = useState(() => node.data.status === 'running')
|
||||
const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
|
||||
const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
|
||||
const requiresExpansion = node.data.status !== 'completed'
|
||||
|| node.data.phases.some(phaseRequiresExpansion)
|
||||
const navigable = useSessions(
|
||||
sessions => navigableMembers(sessions, node.data.phases, sessionId),
|
||||
shallowEqual,
|
||||
@@ -218,14 +247,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
|
||||
return (
|
||||
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
|
||||
<RunHeader
|
||||
count={memberCount}
|
||||
count={totalMembers}
|
||||
name={node.data.name}
|
||||
open={open}
|
||||
requiresExpansion={requiresExpansion}
|
||||
status={node.data.status}
|
||||
t={t}
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
/>
|
||||
{open && (
|
||||
>
|
||||
<div className={css.phaseList}>
|
||||
{node.data.phases.length === 0
|
||||
? <span className={css.empty}>{t('run.empty')}</span>
|
||||
@@ -239,7 +266,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</RunHeader>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -301,90 +301,170 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
|
||||
}
|
||||
|
||||
describe('WorkflowRunPanel', () => {
|
||||
it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
|
||||
it('forces running run and phase content open without false disclosure controls', () => {
|
||||
const view = render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })],
|
||||
})} />)
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
|
||||
const rows = [...view.container.querySelectorAll('[data-disclosure-row]')]
|
||||
expect(rows).toHaveLength(2)
|
||||
for (const row of rows) {
|
||||
expect(row.getAttribute('role')).toBeNull()
|
||||
expect(row.getAttribute('tabindex')).toBeNull()
|
||||
expect(row.getAttribute('aria-expanded')).toBeNull()
|
||||
expect(row.getAttribute('data-expandable')).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('folds each clean transition once and preserves review choices until activity returns', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
expect(screen.getByText('未分阶段')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
const phaseCompleted: WorkflowRunChatData = {
|
||||
...running,
|
||||
phases: [phase({
|
||||
members: [{
|
||||
seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed',
|
||||
}],
|
||||
})],
|
||||
}
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(phaseCompleted)} />)
|
||||
const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('done')).toBeNull()
|
||||
fireEvent.click(phaseHeader)
|
||||
expect(screen.getByText('done')).toBeTruthy()
|
||||
|
||||
const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
|
||||
const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' }
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
|
||||
const runHeader = screen.getByRole('button', { name: /^audit/ })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(runHeader, { key: 'Enter' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
|
||||
fireEvent.keyDown(completedPhase, { key: 'Enter' })
|
||||
expect(screen.getByText('done')).toBeTruthy()
|
||||
fireEvent.keyDown(runHeader, { key: ' ' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(runHeader, { key: ' ' })
|
||||
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.getByText('done')).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
render(<WorkflowRunPanel {...panelProps(terminal)} />)
|
||||
const cleanUpdate: WorkflowRunChatData = {
|
||||
...completed,
|
||||
phases: [phase({
|
||||
members: [{
|
||||
seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed',
|
||||
}],
|
||||
})],
|
||||
}
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(cleanUpdate)} />)
|
||||
expect(screen.getByText('reviewed')).toBeTruthy()
|
||||
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
|
||||
expect(screen.getByText('worker')).toBeTruthy()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
|
||||
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
})
|
||||
|
||||
it('supports root keyboard disclosure and renders a zero-member running state', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'keyboard', status: 'running',
|
||||
phases: [phase({ key: 'research', phase: 'Research' })],
|
||||
it('refolds a phase when a complete activity cycle arrives as one clean update', () => {
|
||||
const firstMember = {
|
||||
seq: 1, label: 'first', childId: 'child-1' as SessionId, status: 'completed' as const,
|
||||
}
|
||||
const phaseClean: WorkflowRunChatData = {
|
||||
name: 'phase-cycle', status: 'running',
|
||||
phases: [phase({ members: [firstMember] })],
|
||||
}
|
||||
const phaseView = render(<WorkflowRunPanel {...panelProps(phaseClean)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.getByText('first')).toBeTruthy()
|
||||
phaseView.rerender(<WorkflowRunPanel {...panelProps({
|
||||
...phaseClean,
|
||||
phases: [phase({ members: [firstMember, {
|
||||
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'completed',
|
||||
}] })],
|
||||
})} />)
|
||||
const header = screen.getByRole('button', { name: /^keyboard/ })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(header, { key: 'ArrowDown' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(header, { key: 'Enter' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(header, { key: ' ' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('Research')).toBeTruthy()
|
||||
expect(screen.getByText('运行中 1')).toBeTruthy()
|
||||
const phaseHeader = screen.getByRole('button', { name: /Research/ })
|
||||
fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' })
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(phaseHeader, { key: 'Enter' })
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(phaseHeader, { key: ' ' })
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('first')).toBeNull()
|
||||
expect(screen.queryByText('second')).toBeNull()
|
||||
})
|
||||
|
||||
cleanup()
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'empty', status: 'running', phases: [],
|
||||
})} />)
|
||||
it('derives the zero-member running and completed states from the current run status', () => {
|
||||
const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] }
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull()
|
||||
expect(screen.getByText('没有启动成员')).toBeTruthy()
|
||||
view.rerender(<WorkflowRunPanel {...panelProps({ ...running, status: 'completed' })} />)
|
||||
const header = screen.getByRole('button', { name: /^empty/ })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('没有启动成员')).toBeNull()
|
||||
fireEvent.click(header)
|
||||
expect(screen.getByText('没有启动成员')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
|
||||
it.each(['failed', 'cancelled', 'interrupted'] as const)(
|
||||
'bubbles a %s member to the run and keeps a matching run outcome open',
|
||||
(status) => {
|
||||
const memberView = render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'member-outcome', status: 'completed',
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: status, childId: CHILD_ID, status }],
|
||||
})],
|
||||
})} />)
|
||||
expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
|
||||
expect(screen.getByText(status)).toBeTruthy()
|
||||
memberView.unmount()
|
||||
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'run-outcome', status,
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'done', childId: CHILD_ID, status: 'completed' }],
|
||||
})],
|
||||
})} />)
|
||||
expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText('done')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps clean sibling phases independent and preserves empty versus absent names', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'running',
|
||||
name: 'audit', status: 'completed',
|
||||
phases: [
|
||||
phase({ key: 'value:0:', phase: '', members: [{
|
||||
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
|
||||
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'completed',
|
||||
}] }),
|
||||
phase({ key: 'missing', phase: null, members: [{
|
||||
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
|
||||
}] }),
|
||||
],
|
||||
})} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
||||
expect(screen.getByText('空成员名')).toBeTruthy()
|
||||
expect(screen.queryByText('second')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
|
||||
const cleanPhase = screen.getByRole('button', { name: /空阶段名/ })
|
||||
expect(cleanPhase.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
|
||||
expect(screen.queryByText('空成员名')).toBeNull()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
||||
fireEvent.click(cleanPhase)
|
||||
expect(screen.getByText('空成员名')).toBeTruthy()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
fireEvent.click(cleanPhase)
|
||||
expect(screen.queryByText('空成员名')).toBeNull()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => {
|
||||
const completed: WorkflowRunChatData = {
|
||||
name: 'repo-audit', status: 'completed',
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }],
|
||||
})],
|
||||
}
|
||||
const completedView = render(<WorkflowRunPanel {...panelProps(completed)} />)
|
||||
const completedHeader = screen.getByRole('button', { name: /^repo-audit/ })
|
||||
expect(completedHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(completedHeader)
|
||||
expect(completedHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
completedView.unmount()
|
||||
|
||||
it('renders mixed and interrupted aggregate status while attention stays visible', () => {
|
||||
const mixed: WorkflowRunChatData = {
|
||||
name: 'repo-audit', status: 'failed',
|
||||
phases: [phase({
|
||||
@@ -395,8 +475,6 @@ describe('WorkflowRunPanel', () => {
|
||||
})],
|
||||
}
|
||||
const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
|
||||
expect([...mixedView.container.querySelectorAll('[data-member-status]')]
|
||||
.map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
|
||||
@@ -404,28 +482,18 @@ describe('WorkflowRunPanel', () => {
|
||||
expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
||||
mixedView.unmount()
|
||||
|
||||
const interrupted: WorkflowRunChatData = {
|
||||
const interruptedView = render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'repo-audit', status: 'interrupted',
|
||||
phases: [
|
||||
phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
|
||||
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
|
||||
],
|
||||
}),
|
||||
phase({
|
||||
key: 'interrupted-only', phase: 'Interrupted only',
|
||||
members: [{
|
||||
seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted',
|
||||
}],
|
||||
}),
|
||||
],
|
||||
}
|
||||
const interruptedView = render(<WorkflowRunPanel {...panelProps(interrupted)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
|
||||
phases: [phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
|
||||
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
|
||||
],
|
||||
})],
|
||||
})} />)
|
||||
expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
|
||||
expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
|
||||
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
||||
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens only a running ordinary-list subagent proven to have this parent', () => {
|
||||
@@ -434,7 +502,6 @@ describe('WorkflowRunPanel', () => {
|
||||
}
|
||||
const openSession = vi.fn()
|
||||
render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
|
||||
expect(openSession).toHaveBeenCalledWith('child-1')
|
||||
})
|
||||
@@ -464,7 +531,6 @@ describe('WorkflowRunPanel', () => {
|
||||
})],
|
||||
}
|
||||
render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user