Merge remote-tracking branch 'origin/feat/web-presenter' into feat/web-web-card

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-07-31 10:42:43 +08:00
253 changed files with 8471 additions and 688 deletions

View File

@@ -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/README.md
README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f
README.zh.md: c9d11bbf6239b4239a4e037dac63b05d3a9a58f7
README.md: 11179cf6676d1b4382816e34285529b51152fe8d
README.zh.md: 100d918287613973604b2f85060572b8ee41d132

View File

@@ -39,6 +39,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |

View File

@@ -39,6 +39,7 @@
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |

View File

@@ -1110,6 +1110,56 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const appended = logOf(sessionId).at(-1) as SessionEvent
return ok(request, { title: normalized, seq: appended.seq })
},
fork: (request) => {
const { sessionId, atSeq } = request.payload
const source = summaryOf(sessionId)
if (source === undefined) {
return err(request, {
code: 'session-not-found',
message: `no session ${sessionId}`,
details: { sessionId },
})
}
const log = logs.get(sessionId) ?? []
const lastSeq = log.at(-1)?.seq ?? -1
const anchoredBoundary = atSeq === undefined
? undefined
: log.find(e => e.type === 'turn/end' && e.seq >= atSeq)
const boundary = anchoredBoundary
?? (atSeq === undefined || atSeq > lastSeq
? log.findLast(e => e.type === 'turn/end')
: undefined)
if (boundary === undefined) {
return err(request, {
code: 'fork-unavailable',
message: atSeq !== undefined && atSeq <= lastSeq
? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}`
: `session ${sessionId} has no completed turn`,
details: { sessionId },
})
}
let cut = boundary.seq + 1
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
const child: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd },
}
logs.set(child.sessionId, log.slice(0, cut))
sessions.push(child)
emitHost({
type: 'host/session-added', sessionId: child.sessionId, blank: false,
parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd },
})
const workspace = workspaces.find(w => w.sessionIds.includes(sessionId))
if (workspace !== undefined) {
workspace.sessionIds = [child.sessionId, ...workspace.sessionIds]
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
return ok(request, { sessionId: child.sessionId })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -1659,6 +1709,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.rename': return this.api.sessions.rename(request)
case 'session.fork': return this.api.sessions.fork(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)

View File

@@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
@@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient {
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),

View File

@@ -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: 766d8516225cd46cb1a3a80c832d1cf55e816140
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692
README.md: b85deeec92fd4da1f342b5536757692f594853a5
README.zh.md: 2dbb66c56ad5687fb299fe030d0abfe451004a62

View File

@@ -28,6 +28,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `N` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.

View File

@@ -28,6 +28,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值直到打开或恢复会话促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd`blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)``N` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
## 会话模型选择
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。

View File

@@ -29,6 +29,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).

View File

@@ -289,6 +289,40 @@ export class SessionManager {
}
}
/**
* Contract session.fork; on success merge the child into summaries
* immediately (same synchronous-addressability guarantee as create). The
* child carries the source's history, so it is never blank; lineage rides
* parentSessionId so the list nests it under its source. A child published
* before Workspace attachment fails is also reconciled into the list.
* @param opts - source session and the optional seq anchoring the cut.
* @returns the fork result (the child session id).
*/
async fork(
opts: { sessionId: SessionId; atSeq?: number },
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const { result } = await this.api.sessions.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
})
const childId = result.ok
? result.value.sessionId
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} })
}
return result
} catch (error) {
return transportError(error)
}
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the

View File

@@ -81,6 +81,22 @@ export class SessionCreateError extends Error {
}
}
/** Structured session-fork failure. */
export class SessionForkError extends Error {
override readonly name = 'SessionForkError'
/**
* @param rpcError - Host business or folded transport error.
* @param sourceSessionId - the session the fork was cut from.
*/
constructor(
readonly rpcError: RpcError,
readonly sourceSessionId: SessionId,
) {
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
@@ -121,6 +137,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
return id
}
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
* @param title - source session's durable title.
* @returns the title assigned to the fork child.
*/
function increasedForkTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
}
const fullWidth = /^(.*?)(\d+)$/u.exec(title)
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
return `${fullWidth[1]}${BigInt(fullWidth[2]) + 1n}`
}
return `${title} (1)`
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
@@ -317,6 +351,42 @@ export class SessionsService implements ISessions {
return result.value.sessionId
}
/**
* Fork a session from a completed-turn prefix of the source (same
* synchronous-addressability guarantee as {@link SessionsService.create}:
* on resolution the child is in the list store and open() can target it).
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
*/
async fork(opts: {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
}): Promise<SessionId> {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
const childId = result.value.sessionId
if (sourceTitle !== undefined) {
const child = this.binding(childId)?.session
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
const renamed = await child.rename(increasedForkTitle(sourceTitle))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
}
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).

View File

@@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient {
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),

View File

@@ -277,6 +277,23 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a fork child published before workspace attachment fails', async () => {
const api = new FakeApiClient()
api.onFork = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'forked but unattached',
details: { sessionId: S2, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.fork({ sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
sessionId: S2,
parentSessionId: S1,
blank: false,
})])
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))

View File

@@ -10,7 +10,7 @@ import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -399,6 +399,69 @@ describe('create', () => {
})
})
describe('fork', () => {
it.each([
['Roadmap', 'Roadmap (1)'],
['Roadmap (1)', 'Roadmap (2)'],
['计划1', '计划2'],
['计划 9', '计划 10'],
])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never,
})
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = (payload) => {
const { title } = payload as { title: string }
return Promise.resolve(ok({ title, seq: 3 }))
}
await expect(b.svc.fork({
sessionId: sid('source'), atSeq: 7, increaseTitle: true,
})).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
title: childTitle,
displayTitle: childTitle,
parentId: 'source',
})
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
expect(b.api.callsOf('session.rename')).toEqual([])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
expect(b.api.callsOf('session.rename')).toEqual([])
})
it('rejects when child rename fails while keeping the published child addressable', async () => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never,
})
await feedList(b, [{ id: 'source' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = () => Promise.resolve(err({
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
}))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
expect(b.svc.binding(sid('child'))).toBeDefined()
})
})
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
const b = bench()

View File

@@ -169,7 +169,7 @@ export class TestSessions implements ISessions {
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
/**
* @param stabilize - the owning runtime's act wrapper.
@@ -392,6 +392,17 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = undefined })
}
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).
* @param opts - source session id, optional cut anchor, and client title policy.
* @returns the source id (no child record is created).
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId> {
this.calls.push({ method: 'fork', args: [opts] })
return Promise.resolve(opts.sessionId)
}
/**
* The session face of a fixture (typed view for assertions; fixture
* behavior methods are grafted onto it).

View File

@@ -201,7 +201,7 @@ describe('sessions', () => {
await runtime.dispose()
})
it('records service-face calls; open() moves the selection and clear() empties it', async () => {
it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' })
@@ -211,9 +211,13 @@ describe('sessions', () => {
runtime.sessions.clear()
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
await expect(runtime.sessions.fork({
sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
})).resolves.toBe('s1')
expect(runtime.sessions.calls).toEqual([
{ method: 'open', args: ['s1'] },
{ method: 'clear', args: [] },
{ method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
])
await runtime.dispose()
})

View File

@@ -22,6 +22,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
@@ -40,7 +42,7 @@ None; this package neither assembles nor sends a provider request.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -22,6 +22,8 @@
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 10` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
@@ -38,9 +40,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **统计行的耗时只覆盖窗口内消息流**LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -263,6 +263,13 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the source view untouched.
})
},
}
},
}, ChatView)

View File

@@ -23,6 +23,10 @@ export interface AssistantMarkdownProps {
interrupted?: boolean | undefined
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through the turn containing this finalized message. */
onFork?: ((seq: number) => void) | undefined
}
function firstLine(text: string): string {
@@ -60,7 +64,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
blocks, streaming, interrupted, time, seq, onFork,
}: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
@@ -91,6 +95,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
text={copyText(blocks)}
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
/>
)}

View File

@@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -377,6 +377,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
streaming={false}
interrupted={node.interrupted}
time={node.time}
seq={node.seq}
onFork={forkAt}
/>
)
}
@@ -385,7 +387,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
return <MessageItem key={item.key} node={node} onFork={forkAt} />
}
return (

View File

@@ -1,5 +1,6 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
// Shared IconActions chrome for user and assistant messages: copy live,
// branch wired through onBranch, date-aware clock,
// optional edit stub.
import { useCallback } from 'react'
import {
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Fork the session at this message. */
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
text, time, clock, edit, onBranch, className,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -48,7 +51,7 @@ export function MessageIconActions({
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>

View File

@@ -16,6 +16,8 @@ import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
@@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
time={node.time}
clock="start"
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
/>
</div>

View File

@@ -419,6 +419,8 @@ export interface ChatViewInjected {
*/
openFile: (path: string) => void
loadOlder: () => void
/** Fork the session through the turn containing the message at `seq`, then open the child. */
forkAt: (seq: number) => void
}
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */

View File

@@ -20,6 +20,8 @@
padding-top: 2px;
border-radius: 14px 14px 0 0;
background: var(--dsw-specific-tip);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.panel::after {
@@ -32,7 +34,52 @@
pointer-events: none;
}
.header {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 16px 4px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-label-tertiary);
outline-offset: -2px;
}
.header:disabled {
cursor: default;
}
.count {
flex: 1 1 auto;
min-width: 0;
font-family: Inter, var(--dsw-font-family);
font-size: 14px;
font-weight: 500;
line-height: 24px;
}
.chevron {
display: grid;
flex: none;
place-items: center;
width: 14px;
height: 14px;
color: var(--dsw-alias-label-tertiary);
}
.list {
max-height: 180px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;

View File

@@ -4,11 +4,12 @@
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useState } from 'react'
import { useEffect, useId, useState } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import css from './QueueDock.module.css'
@@ -22,18 +23,28 @@ export interface QueueDockInjected {
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
/**
* Queue strip: one item renders directly; multiple items default to a
* collapsible count header; an empty queue renders nothing.
*/
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
const queue = useSession(s => s.queue)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
const [collapsed, setCollapsed] = useState(true)
const listId = useId()
useEffect(() => {
if (queue.length === 0 && !collapsed) setCollapsed(true)
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [editing, queue])
}, [collapsed, editing, queue])
if (queue.length === 0) return null
const interactionActive = editing !== null || busy !== null
const expanded = !collapsed || interactionActive
const listVisible = queue.length === 1 || expanded
const applyAction = async (
itemId: QueueItemId,
action: QueueAction,
@@ -63,8 +74,23 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
return (
<div className={css.dock}>
<div className={css.panel}>
<ul className={css.list}>
{queue.map(row => (
{queue.length > 1 && (
<button
type="button"
className={css.header}
aria-controls={listId}
aria-expanded={expanded}
disabled={interactionActive}
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.count}>{queue.length} </span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
</span>
</button>
)}
<ul id={listId} className={css.list} hidden={!listVisible}>
{listVisible && queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (

View File

@@ -19,6 +19,13 @@
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
the justification or command passes the cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the region that actually
scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Tinted full-width header band. */
@@ -40,11 +47,22 @@
background: var(--dsw-alias-state-warn-primary);
}
/* Scroll region: an agent's justification and its command are unbounded model
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
fixed-height column — uncapped, a long command pushed the action row past
the viewport and the approval could not be answered at all. The strip and
the action row stay outside, so the buttons are always on screen. */
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 14px;
/* border-box so the cap is the region's OUTER height: the composer's draft
area counts its padding inside the same number, and the two seats are
only interchangeable if they occupy the same box. */
box-sizing: border-box;
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
padding: 12px 16px 0;
}
/* The model's justification is the panel's message, not a footnote. */
@@ -63,11 +81,15 @@
word-break: break-all;
}
/* Card-level row, not body content. Its padding reproduces the metrics the row
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
margin, neither of which reaches it out here) and the body's former 14px
bottom pad below, so the resting card is unchanged. */
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding: 14px 16px 14px;
}
.allow,

View File

@@ -4,7 +4,11 @@
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. One-shot: the buttons disable
// a right-aligned refuse/allow action row. Justification and command are
// unbounded model text, so they scroll inside the card at the shared composer
// cap (`data-approval-scroll`) and the action row stays outside it — the
// buttons must be reachable no matter how long the command is.
// One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
@@ -53,17 +57,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} /></div>
<div className={css.body}>
{/* Tab stop: the region scrolls once the command passes the cap and
holds nothing focusable of its own, so without one a keyboard-only
user cannot reach the command's tail before answering. */}
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
</div>

View File

@@ -143,6 +143,14 @@
display: flex;
flex: none;
flex-direction: column;
/* One cap for every scrolling text region a composer seat can hold: the
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
takeover panels' bodies top out at the same height, so electing a
takeover never grows the footer past the card it replaces. Declared on
the seat because it is the chain's only shared ancestor — fallback and
elected overlay are siblings — and custom properties inherit down to
whichever entry is mounted. */
--dsh-composer-text-max-height: 336px;
}
/* Active phase: header is ordinary column chrome above the scrollport (not

View File

@@ -209,7 +209,9 @@
.mirror {
visibility: hidden;
pointer-events: none;
max-height: 336px;
/* 14-line cap, shared with the composer takeovers (declared on
ConversationRoot .composerSeat). */
max-height: var(--dsh-composer-text-max-height);
overflow: hidden;
}

View File

@@ -122,6 +122,13 @@ describe('conversation slot inject surface', () => {
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
chatView.injected.forkAt(17)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
})
expect(b.runtime.sessions.calls).toContainEqual({
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
})
await b.runtime.dispose()
})

View File

@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
@@ -120,9 +121,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openDetails,
openFile,
loadOlder,
forkAt,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
}
describe('chat-flow derivation', () => {
@@ -194,6 +196,16 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
const view = render(<h.ChatView {...h.props} />)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* removal, failure notices, and live retirement.
* collapse state, removal, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -78,16 +78,109 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
const single = snapshotWith([row('i-1', 'one')])
const source = liveSession(single)
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
expect(view.getByText('one')).toBeTruthy()
act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
expect(view.queryByText('one')).toBeNull()
expect(view.queryByText('two')).toBeNull()
fireEvent.click(header)
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText('one')).toBeTruthy()
expect(view.getByText('two')).toBeTruthy()
fireEvent.click(header)
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('one')).toBeNull()
})
it('keeps an active single-row editor visible when another item arrives', () => {
const single = snapshotWith([row('i-edit', 'before')])
const source = liveSession(single)
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
fireEvent.click(view.getByLabelText('编辑排队消息'))
fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
act(() => {
source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
})
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header).toHaveProperty('disabled', true)
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
expect(view.getByText('second')).toBeTruthy()
fireEvent.click(view.getByLabelText('取消编辑'))
expect(header).toHaveProperty('disabled', false)
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('second')).toBeNull()
})
it('keeps an in-flight row action visible when another item arrives', async () => {
const single = snapshotWith([row('i-remove', 'remove me')])
const source = liveSession(single)
let finishUpdate: (() => void) | undefined
const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
const view = render(
<QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(view.getByLabelText('删除排队消息'))
act(() => {
source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
})
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header).toHaveProperty('disabled', true)
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText('remove me')).toBeTruthy()
expect(view.getByText('second')).toBeTruthy()
act(() => { finishUpdate?.() })
await waitFor(() => {
expect(header).toHaveProperty('disabled', false)
expect(header.getAttribute('aria-expanded')).toBe('false')
})
})
it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
const source = liveSession(first)
const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
expect(view.getByText('one')).toBeTruthy()
act(() => { source.push(snapshotWith([])) })
expect(view.container.innerHTML).toBe('')
act(() => {
source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
})
const header = view.getByRole('button', { name: '2 条排队消息' })
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('three')).toBeNull()
})
it('renders active actions and disables editing for mixed-content rows', () => {
const snap = snapshotWith([
row('i-1', '第一条排队消息'),
row('i-2', null, 'image [image]'),
])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(4)
expect(container.querySelectorAll('button')).toHaveLength(5)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
@@ -162,10 +255,11 @@ describe('QueueDock', () => {
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getAllByLabelText } = render(
const { getAllByLabelText, getByRole } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })

View File

@@ -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-question/README.md
README.md: 0700375758774610fcd897b9a3e16484206a871d
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3

View File

@@ -6,6 +6,8 @@ Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user`
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.

View File

@@ -6,6 +6,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode``exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权HTTP 交付成功不会在本地移除待处理状态。
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale``question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。

View File

@@ -0,0 +1,113 @@
/* Plan-review takeover: the waiting-approval card language (amber strip on a
floating capsule, right-aligned actions) applied to a reviewed plan. Kept as
its own module rather than shared with ui-conversation's ApprovalPanel: the
two takeovers agree on tokens and geometry, not on content — this one's body
is scrollable markdown, that one's is a headline plus a command line. Warn
semantics ride the alias state tokens; no hardcoded colors. */
/* Mirrors the question card's frame so the takeover is a content swap. */
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
}
.card {
display: flex;
overflow: hidden;
flex-direction: column;
width: 100%;
max-width: 776px;
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the plan, so the
strip and the decision row stay reachable on a long plan. */
max-height: min(60vh, 520px);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
/* Elevated surface in dark: the plan body inside scrolls once the card hits
the cap above, so the thumb takes the l2 pair (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.card,
.card * {
box-sizing: border-box;
}
/* Tinted full-width header band, as on the approval takeover. */
.strip {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dsw-alias-state-warn-primary);
}
/* The plan is the panel's message: it takes the whole body and the scroll. */
.body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 16px 4px;
font-size: 14px;
line-height: 22px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 12px;
padding: 8px 16px 12px;
}
.feedback {
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
.card {
border-radius: 16px;
}
.body {
padding: 10px 12px 4px;
}
.footer {
align-items: flex-end;
padding: 8px 12px 10px;
}
}

View File

@@ -0,0 +1,100 @@
// PlanReviewPanel: the composer takeover for a question carrying the
// `plan-review` presentation intent. A plan under review is one decision over
// one body of markdown, so it takes the waiting-approval card shape — tinted
// strip, content, right-aligned action row — instead of the generic question
// flow's pager, numbered options, skip and custom-answer affordances, which
// read as a quiz the user is being graded on.
//
// The three actions are the whole decision surface: approve and decline answer
// the question with the option labels the asker offered (localised copy on the
// buttons, the asker's descriptions as their tooltips), while "discuss"
// dismisses the request so the composer returns and the user can simply say
// what they want. Dismissal is the generic flow's own cancel verb, promoted to
// a labelled button because in a two-outcome decision it is the third real
// answer, not an escape hatch.
import { useState } from 'react'
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
import css from './PlanReviewPanel.module.css'
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
export type PlanReviewPanelProps =
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
/**
* Optional-prop spread for a decision button's tooltip: `title` is optional on
* the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined.
*
* @param description - the asker's option description, when it carries one.
* @returns The `title` prop to spread, or nothing.
*/
function tooltip(description: string | undefined): { title?: string } {
return description === undefined ? {} : { title: description }
}
/**
* Render a plan review as a decision card.
*
* @param props - the question domain face, the narrowed plan review, and `t`.
* @returns The plan-review takeover for this request.
*/
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
// One-shot latch shaped like the approval takeover's: the panel leaves only
// when the host's resolved frame lands, so until then a second click must
// not re-fire. A failed send (rejected receipt / transport) re-arms it and
// shows why, since nothing else would tell the user the click was lost.
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const settle = (send: () => Promise<void>): void => {
setBusy(true)
setError(null)
void send().catch((cause: unknown) => {
setBusy(false)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const decide = (label: string): void => {
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
}
const decline = review.decline
return (
<div className={css.frame} data-plan-review-key={pending.key}>
<section className={css.card} aria-label={review.question}>
<div className={css.strip}>
<span className={css.dot} />
{t('plan.header')}
</div>
<div className={css.body} data-plan-review-scroll>
<MarkdownText text={review.plan} />
</div>
<div className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.actions}>
<Button
size="sm" variant="ghost" icon={<IconEditOutline16 />}
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
>
{t('plan.discuss')}
</Button>
{decline !== undefined && (
<Button
size="sm" variant="outline" {...tooltip(decline.description)}
disabled={busy} onClick={() => { decide(decline.label) }}
>
{t('plan.decline')}
</Button>
)}
<Button
size="sm" variant="primary" {...tooltip(review.approve.description)}
disabled={busy} onClick={() => { decide(review.approve.label) }}
>
{t('plan.approve')}
</Button>
</div>
</div>
</section>
</div>
)
}

View File

@@ -5,9 +5,10 @@ import {
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
PendingQuestion,
PendingQuestion, planReviewOf,
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
@@ -46,14 +47,24 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
*
* One takeover, two shapes: a request that declares a presentation intent this
* package renders takes that shape (a plan review is one decision over one
* plan, not a question set), and every other request takes the generic flow.
* The routing lives here, at the one entry that owns the composer seat, so
* neither shape can claim a request the other is already rendering.
*
* @param props - the selector-matched pending question carrier plus the framework standard kit.
* @returns The question flow for this request.
* @returns The question flow, or the intent's own surface, for this request.
*/
export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
return <QuestionFlow key={question.key} pending={question} t={props.t} />
const review = useMemo(() => planReviewOf(question.questions), [question])
return review === undefined
? <QuestionFlow key={question.key} pending={question} t={props.t} />
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
}
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {

View File

@@ -19,6 +19,71 @@ export type QuestionWait = PendingWait<'question'>
/** One structured answer batch covering every question of the request. */
export type QuestionAnswer = QuestionResponsePayload['answer']
/** One question of the request, as the carrier payload carries it. */
type QuestionItem = QuestionWait['payload']['questions'][number]
/** One option the asker offered on a question. */
type QuestionOption = NonNullable<QuestionItem['options']>[number]
/**
* A request narrowed to the `plan-review` presentation intent: everything the
* decision card renders and answers with, so the panel never re-reads the
* request shape. `approve` and `decline` are the asker's own options — an
* answer must carry one of those labels verbatim — and `plan` is the markdown
* body under review.
*/
export interface PlanReview {
/** The reviewed question's id, echoed in the answer. */
id: string
/** The question text, kept as the card's accessible name. */
question: string
/** The plan markdown under review. */
plan: string
/** The option that approves the plan. */
approve: QuestionOption
/** The option that declines it; absent when the asker offered no other option. */
decline?: QuestionOption
}
/**
* Narrow a request to a renderable plan review, or return undefined to leave it
* to the generic question flow.
*
* The card is one decision over one plan, and it claims a request only when it
* can send every answer that request allows — an intent changes the layout,
* never which answers are reachable. So the batch must be a single question
* that declares the intent, carries the plan as its detail, offers the approve
* label the intent names, and is a binary single choice: at most one option
* besides approve, and not multi-select. A third option or a multi-select batch
* has answers two buttons cannot express, so the generic flow keeps it — as it
* keeps any request whose intent the asker's own service would have rejected,
* because the client sits downstream of a wire boundary and every request must
* stay answerable.
*
* @param questions - the request's whole question batch.
* @returns The narrowed review, or undefined when the generic flow owns it.
*/
export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | undefined {
if (questions.length !== 1) return undefined
// Length-checked above; the index read is the narrowing tax, not a guess.
const question = questions[0] as QuestionItem
const intent = question.intent
if (intent?.kind !== 'plan-review' || question.detail === undefined) return undefined
if (question.multiSelect === true) return undefined
const options = question.options ?? []
if (options.length > 2) return undefined
const approve = options.find(option => option.label === intent.approve)
if (approve === undefined) return undefined
const decline = options.find(option => option.label !== intent.approve)
return {
id: question.id,
question: question.question,
plan: question.detail,
approve,
...(decline === undefined ? {} : { decline }),
}
}
/**
* Question domain face over the carrier: render identity and questions
* transparently forwarded; answer/cancel own the wire encoding (the ok value

View File

@@ -5,6 +5,12 @@
* question carrier (matched prop), and the whole behavior surface rides the
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
*
* One entry, two shapes: the composer renders a request that declares a
* presentation intent as that intent's own surface (`plan-review` → the plan
* decision card) and every other request as the generic question flow. A
* separate chain entry per shape would race the same carrier, so the shape
* choice lives inside this entry — see QuestionComposer.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -15,7 +21,9 @@ import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export type {
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
} from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {

View File

@@ -11,6 +11,10 @@ export const zh = {
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.next': '下一题',
'plan.header': '计划待审',
'plan.approve': '确认执行',
'plan.decline': '拒绝',
'plan.discuss': '去聊天里说',
} satisfies Record<string, string>
/** The question namespace key union. */
@@ -27,4 +31,8 @@ export const en = {
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.next': 'Next',
'plan.header': 'Plan review',
'plan.approve': 'Approve',
'plan.decline': 'Refuse',
'plan.discuss': 'Chat about it',
} satisfies Record<QuestionKey, string>

View File

@@ -0,0 +1,228 @@
// @vitest-environment jsdom
// The plan-review takeover, driven through the composer entry that routes to
// it: a request carrying the intent must reach the decision card and answer
// with the asker's own option labels, and a request that does not (or cannot)
// must keep the generic question flow.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
const kit = {
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
t: seatOver(zh, commonZh),
}
const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n'
/** The plan-mode request shape: one question, the plan as detail, approve named. */
const questions = (): QuestionWait['payload']['questions'] => [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: PLAN,
options: [
{ label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
],
intent: { kind: 'plan-review', approve: 'Approve' },
}]
/** Carrier fixture over a scripted respond carrier. */
function wait(
payload: QuestionWait['payload'] = { questions: questions() },
respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true })),
) {
return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond }
}
/** The client-response envelope respond must have received for a decision. */
function decidedEnvelope(label: string) {
return {
type: 'client-response', rpcId: RpcId('q-1'),
result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
}
}
describe('planReviewOf', () => {
it('narrows a plan-review request to its decision, options included', () => {
expect(planReviewOf(questions())).toEqual({
id: 'plan-review',
question: 'Approve this plan and leave plan mode?',
plan: PLAN,
approve: { label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
decline: { label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
})
})
it('leaves the decline absent when the asker offered approve alone', () => {
const [question] = questions()
const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never])
expect(review?.approve).toEqual({ label: 'Approve' })
expect(review === undefined ? true : 'decline' in review).toBe(false)
})
it.each([
['a batch of more than one question', () => [...questions(), ...questions()]],
['no intent at all', () => [{ ...questions()[0] as object, intent: undefined }]],
['an intent without the plan as detail', () => [{ ...questions()[0] as object, detail: undefined }]],
['an intent whose approve names no option', () => [{
...questions()[0] as object, intent: { kind: 'plan-review', approve: 'Ship it' },
}]],
['an intent with no options at all', () => [{ ...questions()[0] as object, options: undefined }]],
// Two buttons cannot send a third label or a combination, and the generic
// flow can: an intent never costs the user a reachable answer.
['a third option the card could not offer', () => [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }, { label: 'Start over' }],
}]],
['a multi-select decision', () => [{ ...questions()[0] as object, multiSelect: true }]],
])('declines %s, leaving the request to the generic flow', (_case, build) => {
expect(planReviewOf(build() as never)).toBeUndefined()
})
it('declines an empty batch, which the generic flow reports as such', () => {
expect(planReviewOf([])).toBeUndefined()
})
})
describe('PlanReviewPanel', () => {
it('renders the plan under a review strip, with none of the quiz affordances', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
// The plan renders as markdown, so its heading is a heading.
expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
expect(screen.getByText('render the rows')).toBeTruthy()
// The question text stays as the card's accessible name rather than a title
// that reads like a test item.
expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()
// No pager, no numbered options, no skip, no custom answer.
expect(screen.queryByText('1 / 1')).toBeNull()
expect(screen.queryByRole('radio')).toBeNull()
expect(screen.queryByText(zh['action.skip'])).toBeNull()
expect(screen.queryByRole('textbox')).toBeNull()
})
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
const approve = screen.getByRole('button', { name: zh['plan.approve'] })
expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
fireEvent.click(approve)
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve'))
// One-shot: every action locks until the host's resolved frame lands.
expect(approve.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
fireEvent.click(approve)
expect(respond).toHaveBeenCalledTimes(1)
})
it('answers with the asker\'s decline label', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
})
it('dismisses the request so the composer returns for a plain message', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(respond).toHaveBeenCalledWith({
type: 'client-response', rpcId: RpcId('q-1'),
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
})
it('omits the tooltip for an option carrying no description', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
})
it('hides the decline action when the asker offered approve alone', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object, options: [{ label: 'Approve' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
})
it('re-arms the actions and says why when the decision does not land', async () => {
const { carrier, respond } = wait(
{ questions: questions() },
vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
const failure = await screen.findByText('question response rejected: not-pending')
expect(failure.getAttribute('role')).toBe('status')
// Re-armed for the retry: a lost click must not leave a dead card.
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
expect(respond).toHaveBeenCalledTimes(2)
})
it('reports a non-Error transport failure as its stringified value', async () => {
// A non-Error rejection is the case under test: a carrier can reject with
// anything, and the panel must still show the user something.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(await screen.findByText('socket gone')).toBeTruthy()
})
it('carries the same decision surface in English', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByText('Plan review')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy()
})
})

View File

@@ -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: a1b58f4abe0925be3b426d10344777e46caa9ba0
README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5
README.md: 860c24b8a25a1e9968261f586c16163579131a1c
README.zh.md: 5a8e88051fc6f4fd46c5f2f6dcdc185eb4559ac6

View File

@@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba
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. 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 **Open local folder...** 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). 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. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. 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 Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
## Model Experience
@@ -18,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions.
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -6,6 +6,8 @@
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框并以该行的显示标题预填客户端不设名称冲突规则host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
## 模型体验
@@ -18,5 +20,5 @@
## 已知限制与暂缓事项
- **没有 Session 删除与 fork 控件**Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果Rename 已接线);删除 Workspace 注册记录不会删除 Session。
- **没有 Session 删除控件**Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -83,7 +83,7 @@ interface DragState {
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the browser root (the query outlives the tree). */
@@ -98,13 +98,12 @@ type SessionTreeProps = Pick<
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, workspaces, query,
useSessions, startSession, open, forkSession, workspaces, query,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
// Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState<DragState | null>(null)
const currentGroup = current === undefined
@@ -116,8 +115,8 @@ function SessionTree({
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
[list, workspaces, expandedProjects, expandedSessions, query],
() => deriveGroups(list, workspaces, { expandedProjects, query }),
[list, workspaces, expandedProjects, query],
)
const now = Date.now()
@@ -128,7 +127,7 @@ function SessionTree({
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{groups.map(group => (
// Group section: header row + expanded session subtree. The
// Group section: header row + expanded top-level session rows. The
// inter-group breathing room (former flat-list batch separator)
// is the section's own margin (WorkspaceBrowser.module.css).
<div key={group.key} className={css.groupSection}>
@@ -152,7 +151,7 @@ function SessionTree({
}}
/>
{group.sessions.map((node, index) => {
// Draggable: real-workspace group roots outside search. The drag
// Draggable: real-workspace session rows outside search. 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 && query === ''
@@ -170,15 +169,15 @@ function SessionTree({
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 roots = group.sessions
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 : roots[index + 1]?.id
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 = roots.findIndex(r => r.id === drag.sessionId)
const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor)
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)
@@ -190,12 +189,11 @@ function SessionTree({
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={open}
onRename={onSessionRename}
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
onFork={forkSession}
drag={dragProps}
/>
)
@@ -209,7 +207,7 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query'>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
@@ -223,14 +221,11 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={list.current}
now={now}
onOpen={open}
onRename={onSessionRename}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
flat
onFork={forkSession}
/>
))}
</div>
@@ -254,6 +249,7 @@ export function WorkspaceBrowser({
startSession,
open,
renameSession,
forkSession,
renameWorkspace,
deleteWorkspace,
insertSessionBefore,
@@ -462,11 +458,12 @@ export function WorkspaceBrowser({
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
forkSession={forkSession}
workspaces={workspaces}
startSession={startSession}
open={open}

View File

@@ -95,6 +95,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
open: (sessionId: SessionId) => void
/** Rename a Session (explicit user title; resolves on host acceptance). */
renameSession: (sessionId: SessionId, title: string) => Promise<void>
/** Fork a Session at its last completed turn and open the child. */
forkSession: (sessionId: SessionId) => void
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */

View File

@@ -59,6 +59,13 @@ export function apply(ctx: ClientContext): void {
const result = await session.rename(title)
if (!result.ok) throw new Error(result.error.message)
},
forkSession: (sessionId) => {
ctx.sessions.fork({ sessionId, increaseTitle: true })
.then((childId) => { ctx.sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the current selection.
})
},
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

View File

@@ -39,9 +39,7 @@
height: 20px;
}
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
gap to the title — the slots butt together, so the row gap is zeroed and
the title carries its own margins. */
/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
.sessionRow {
height: 34px;
gap: 0;
@@ -168,7 +166,7 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Drag reorder insert line (workspace-group roots): 2px accent above or
/* Drag reorder insert line (workspace-group session rows): 2px accent above or
below the hovered row, drawn with box-shadow so no layout shift. */
.sessionRow.dropBefore {
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
@@ -233,33 +231,9 @@
color: var(--dsw-alias-label-primary);
}
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
so titles align across sibling rows. Duplicates the .iconButton reset instead
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
left the raw UA button box showing. */
.twist {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 20px;
border: none;
border-radius: 4px;
padding: 0;
background: transparent;
cursor: pointer;
}
.twist:hover {
color: var(--dsw-alias-label-primary);
}
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
stays one step darker (tertiary, #81858C) per the cell spec. Declared last
to win over the composed .iconButton color. */
.chevron,
.twist {
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
darker (tertiary, #81858C) per the cell spec. */
.chevron {
color: var(--dsw-alias-label-caption);
}

View File

@@ -2,8 +2,8 @@
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename/Delete and session Rename; the session and workspace
* hover cards are suppressed while a menu is open.
* except workspace Rename/Delete and session Rename/Fork; the session and
* workspace hover cards are suppressed while a menu is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -16,9 +16,6 @@ import type { GroupNode, SessionNode } from '../tree.ts'
import { formatRelativeTime } from '../tree.ts'
import css from './Rows.module.css'
/** Indent step per tree level: one 16px slot (figma session cell). */
const INDENT_STEP = 16
const SESSION_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
@@ -135,16 +132,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
}
/**
* One session subtree: the node's own 34px row (indent by depth, expand
* twist when it has children, running dot, relative time) plus its visible
* children, recursively — the component tree mirrors the derived tree.
* One top-level 34px session row with running dot and relative time.
* @param props.node - derived session node.
* @param props.depth - 0 = directly under the group header.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onToggle - unfold/fold a subtree by id.
* @returns the node's row followed by its children.
* @returns the session row.
*/
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
@@ -161,7 +154,7 @@ function SessionHoverContent({ node, now }: { node: SessionNode; now: number })
}
/**
* Root-row drag wiring supplied by the group owner (workspace groups only).
* Session-row drag wiring supplied by the group owner (workspace groups only).
* `drop` reports the half of the row the pointer released on: 'before'
* inserts above this row, 'after' below it (the owner resolves the anchor).
*/
@@ -184,26 +177,22 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
/** Open the browser-owned session rename dialog (row menu action). */
onRename: (id: SessionNode['id'], currentTitle: string) => void
onToggle: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group roots outside search). */
/** Fork a session at its last completed turn (row menu action). */
onFork: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group sessions outside search). */
drag?: RowDragProps | undefined
/** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
flat?: boolean
}) {
const row = node
const selected = node.id === currentId
const [menuOpen, setMenuOpen] = useState(false)
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
const ownRow = (
<div
className={clsx(
@@ -212,8 +201,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
draggable={drag !== undefined}
onDragStart={drag === undefined
@@ -239,18 +226,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
drag.drop(rowHalf(e))
}}
>
{row.hasChildren && !flat
? (
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: null}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
@@ -261,7 +236,8 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
items={SESSION_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
if (id === 'rename') onRename(node.id, row.title)
if (id === 'fork') onFork(node.id) // delete stays visual-only.
}}
portal
closeOnPointerLeave
@@ -280,24 +256,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
</div>
)
return (
<>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
disabled={menuOpen || drag?.active === true}
/>
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onRename={onRename}
onToggle={onToggle}
/>
))}
</>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
disabled={menuOpen || drag?.active === true}
/>
)
}

View File

@@ -11,20 +11,15 @@ export const UNGROUPED_KEY = ''
/** Display label for the ungrouped bucket row. */
export const UNGROUPED_LABEL = 'Ungrouped'
/** One session node of a group's visible tree (34px row; children render indented one step). */
/** One top-level session row in a group or the flat list. */
export interface SessionNode {
id: SessionId
title: string
/** Visible children, already expansion/search-filtered (empty when folded). */
children: readonly SessionNode[]
/** The session HAS children in the data (the twist renders even while folded). */
hasChildren: boolean
expanded: boolean
running: boolean
updatedAt: number
}
/** One workspace group section: header row facts + the visible session tree. */
/** One workspace group section: header row facts + visible top-level session rows. */
export interface GroupNode {
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
key: string
@@ -39,14 +34,13 @@ export interface GroupNode {
expanded: boolean
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
containsCurrent: boolean
/** Visible roots (empty while the group is folded). */
/** Visible session rows (empty while the group is folded). */
sessions: readonly SessionNode[]
}
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
/** Viewing state consumed by the derivation. */
export interface TreeView {
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
@@ -56,9 +50,7 @@ interface Group {
cwd: string | undefined
createdAt: number | undefined
label: string
summaries: Map<SessionId, SessionSummary>
roots: SessionId[]
children: Map<SessionId, SessionId[]>
sessions: SessionSummary[]
}
/**
@@ -89,7 +81,7 @@ function sessionTitle(session: SessionSummary): string {
return session.blank ? 'New Session' : session.displayTitle
}
/** Build one group's parent/child tree from an ordered member list. */
/** Build one group without projecting session lineage into presentation. */
function buildGroup(
key: string,
workspaceId: WorkspaceId | undefined,
@@ -99,54 +91,11 @@ function buildGroup(
members: readonly SessionSummary[],
order: 'account' | 'recency',
): Group {
const summaries = new Map(members.map(m => [m.id, m]))
const children = new Map<SessionId, SessionId[]>()
const roots: SessionSummary[] = []
for (const m of members) {
// A session is a tree child only when its parent lives in the same
// group; cross-group or unknown parents degrade to group roots.
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
const kids = children.get(m.parentId)
if (kids === undefined) children.set(m.parentId, [m.id])
else kids.push(m.id)
} else {
roots.push(m)
}
}
// Workspace order is the member iteration order (workspace.sessionIds), so
// attached groups keep insertion order; Ungrouped sorts by recency.
if (order === 'recency') {
roots.sort(byRecency)
for (const kids of children.values()) {
kids.sort((a, b) => {
const sa = summaries.get(a)
const sb = summaries.get(b)
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
if (sa === undefined || sb === undefined) return 0
return byRecency(sa, sb)
})
}
}
const rootIds = roots.map(r => r.id)
// parentId cycles (host bug) leave members unreachable from any root;
// surface them as extra roots — the flatten walk's visited set stops
// loops. Each node sits in at most one kids list and roots have no
// in-group parent, so the scan pushes every reachable node exactly once.
const reachable = new Set<SessionId>(rootIds)
const stack = [...rootIds]
while (stack.length > 0) {
const top = stack.pop()
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
if (top === undefined) break
for (const kid of children.get(top) ?? []) {
reachable.add(kid)
stack.push(kid)
}
}
for (const m of members) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children }
const sessions = [...members]
// Workspace order is workspace.sessionIds; only Ungrouped lacks an account
// order and therefore falls back to recency.
if (order === 'recency') sessions.sort(byRecency)
return { key, workspaceId, cwd, createdAt, label, sessions }
}
/**
@@ -181,72 +130,24 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
return groups
}
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
function sessionNode(s: SessionSummary): SessionNode {
return {
id: s.id,
title: sessionTitle(s),
children,
hasChildren,
expanded,
running: s.running,
updatedAt: s.updatedAt,
}
}
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return null
const kids = g.children.get(id) ?? []
const expanded = expandedSessions.has(id)
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
return sessionNode(s, children, kids.length > 0, expanded)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/** Matched sessions plus their ancestor chains (forced visible under search). */
function searchVisible(g: Group, q: string): Set<SessionId> {
const visible = new Set<SessionId>()
for (const m of g.summaries.values()) {
if (!sessionTitle(m).toLowerCase().includes(q)) continue
let cur: SessionSummary | undefined = m
while (cur !== undefined && !visible.has(cur.id)) {
visible.add(cur.id)
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
}
}
return visible
}
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id) || !visible.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return null
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
return sessionNode(s, children, kids.length > 0, kids.length > 0)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/**
* Derive the nested workspace browser group structure.
* Derive the workspace browser groups with every session as a top-level row.
*
* Normal mode: every group shows; sessions populate under expanded groups,
* descending only into expanded sessions. Search mode (non-blank query,
* preserving Host account order. Search mode (non-blank query,
* case-insensitive display-title substring): expansion state is ignored —
* matched sessions and their ancestor chains are forced visible, groups
* without a display-title or label hit are dropped, and a label-only hit
* keeps the bare group header. Blank sessions are excluded everywhere.
* matching sessions are forced visible, groups without a display-title or
* label hit are dropped, and a label-only hit
* keeps the bare group header. Non-current blank sessions are excluded.
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param view - local expansion arrays and search query.
@@ -259,7 +160,6 @@ export function deriveGroups(
): GroupNode[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
@@ -274,24 +174,24 @@ export function deriveGroups(
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? buildVisible(g, expandedSessions) : [],
sessions: expanded ? g.sessions.map(sessionNode) : [],
})
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
const matches = g.sessions.filter(session => sessionTitle(session).toLowerCase().includes(q))
if (matches.length === 0 && !g.label.toLowerCase().includes(q)) continue
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded: visible.size > 0,
sessionCount: g.sessions.length,
expanded: matches.length > 0,
containsCurrent: g.key === currentGroup,
sessions: buildSearch(g, visible),
sessions: matches.map(sessionNode),
})
}
}
@@ -301,9 +201,8 @@ export function deriveGroups(
/**
* Derive the flat session list ("In one list" mode): every session — fork
* children included — as a top-level row, strictly newest-first. No grouping,
* no parent/child adjacency; rows reuse SessionNode with children always
* empty so the renderer stays branch-free. Search mode filters by
* case-insensitive display-title substring.
* no parent/child adjacency. Search mode filters by case-insensitive
* display-title substring.
* @param list - sessions list snapshot.
* @param view - the search query (expansion state does not apply).
* @returns flat rows in render order.
@@ -318,7 +217,7 @@ export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>
rows.push(s)
}
rows.sort(byRecency)
return rows.map(s => sessionNode(s, [], false, false))
return rows.map(sessionNode)
}
/**

View File

@@ -19,11 +19,17 @@ async function bench() {
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
const fork = vi.fn(async () => 'forked' as never)
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
ctx.provide('sessions', { open, clear, binding, fork } as never)
return {
ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename,
insertSessionBefore, open, clear, renameSession, binding, fork,
}
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -66,6 +72,14 @@ describe('ui-workspace apply', () => {
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
browser.open('session' as never)
expect(b.open).toHaveBeenCalledWith('session')
await browser.renameSession('session' as never, 'renamed session')
expect(b.binding).toHaveBeenCalledWith('session')
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
browser.forkSession('session' as never)
await vi.waitFor(() => {
expect(b.open).toHaveBeenCalledWith('forked')
})
expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true })
await browser.renameWorkspace('ws' as never, 'renamed')
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)

View File

@@ -56,46 +56,22 @@ describe('workspace browser rows', () => {
expect(onToggle).toHaveBeenCalledOnce()
})
it('renders and operates selected, running, recursive Session nodes', () => {
const child: SessionNode = {
id: sid('child'), title: 'Child', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const parent: SessionNode = {
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
expanded: true, running: true, updatedAt: 0,
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', running: true, updatedAt: 0,
}
const onOpen = vi.fn()
const onToggle = vi.fn()
const view = render(
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle} />,
render(
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onFork={vi.fn()} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
expect(parentRow.getAttribute('aria-selected')).toBe('true')
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
expect(childRow.getAttribute('aria-selected')).toBe('false')
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(onToggle).toHaveBeenCalledWith(parent.id)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(parentRow)
fireEvent.click(childRow)
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle}
/>,
)
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
const row = screen.getByRole('treeitem')
expect(row.getAttribute('aria-selected')).toBe('true')
expect(row.hasAttribute('aria-expanded')).toBe(false)
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
fireEvent.click(row)
expect(onOpen).toHaveBeenCalledWith(node.id)
})
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
@@ -156,15 +132,15 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
})
it('session row menu opens without opening the session and dispatches rename', () => {
it('session row menu opens without opening the session and dispatches rename and fork', () => {
const onOpen = vi.fn()
const onRename = vi.fn()
const onFork = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'One', running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} />)
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
@@ -173,9 +149,10 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
expect(onOpen).not.toHaveBeenCalled()
// Fork and Delete stay visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
expect(onFork).toHaveBeenCalledWith(node.id)
// Delete stays visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
expect(onRename).toHaveBeenCalledOnce()
@@ -185,25 +162,14 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('flat variant renders no twist even for a parent and ignores toggling', () => {
const node: SessionNode = {
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} flat />)
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
expanded: false, running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
@@ -226,11 +192,10 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
@@ -242,13 +207,12 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
@@ -265,8 +229,8 @@ describe('workspace browser rows', () => {
const active = dragProps({ active: true, marker: 'before' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={active} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -279,8 +243,8 @@ describe('workspace browser rows', () => {
const after = dragProps({ active: true, marker: 'after' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={after} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})

View File

@@ -21,7 +21,7 @@ const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = [], query = '') => ({
expandedProjects, expandedSessions: [] as string[], query,
expandedProjects, query,
})
describe('deriveGroups', () => {
@@ -74,7 +74,7 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessionCount).toBe(1)
})
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
const parent = summary('parent', 1)
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
const newChild = { ...summary('new-child', 20), parentId: parent.id }
@@ -87,15 +87,13 @@ describe('deriveGroups', () => {
const groups = deriveGroups(
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
{ expandedProjects: [UNGROUPED_KEY], query: '' },
)
expect(groups).toHaveLength(1)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
])
expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
newChild.id, tieA.id, tieB.id, oldChild.id,
cycleB.id, cycleA.id, orphan.id, self.id, parent.id,
])
// Equal timestamps use ids as a deterministic tiebreak in either input order.
@@ -113,7 +111,7 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
})
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
it('searches rows independently of lineage and keeps label-only hits', () => {
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
@@ -124,8 +122,8 @@ describe('deriveGroups', () => {
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
match.id, self.id, orphan.id, cycleA.id, cycleB.id,
])
const labelOnly = deriveGroups(
@@ -157,8 +155,6 @@ describe('deriveFlat', () => {
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
// Rows are branch-free: no children, no expansion.
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
})
it('search filters by case-insensitive display-title substring', () => {

View File

@@ -56,6 +56,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
startSession: vi.fn(),
open: vi.fn(),
renameSession: vi.fn(async () => {}),
forkSession: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
@@ -124,7 +125,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('alpha-s')).toBeNull()
})
it('unfolds a session subtree through the row twist', () => {
it('renders a fork child as a top-level row without a session twist', () => {
const parent = summary('parent-s', 2)
const child = { ...summary('child-s', 1), parentId: parent.id }
mount({
@@ -132,11 +133,9 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('child-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('child-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('child-s')).toBeNull()
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
})
it('auto-expands the selected session group and starts a session from the group ', () => {

View File

@@ -748,6 +748,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'settings',
summary: 'Abstract settings service.',
methods: [
{
signature: 'register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>',
jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */',
},
{
signature: 'describe(): SettingsDescriptor[]',
jsDoc: '/**\n * Describe every registered namespace for configuration surfaces.\n * @returns one descriptor per registered namespace, in registration order.\n */',
},
{
signature: 'get(ns: SettingsNamespace): unknown',
jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */',
},
{
signature: 'async update(ns: SettingsNamespace, patch: object): Promise<void>',
jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted. Writes to one\n * namespace are serialized: concurrent updates apply in call order, each\n * merging over the previous write\'s committed section.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */',
},
{
signature: 'async replace(ns: SettingsNamespace, section: object): Promise<void>',
jsDoc: '/**\n * Replace one registered namespace\'s user section wholesale, validate,\n * persist, then commit and emit. Keys absent from `section` fall back to the\n * composition `base` and schema defaults — this is the removal/reset path a\n * merge-only patch cannot express (`replace({})` re-inherits everything).\n * @param ns - the registered namespace to replace.\n * @param section - the complete next user section.\n */',
},
],
},
{
key: 'skills',
summary: 'Registry of skill providers.',
@@ -1315,6 +1341,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
},
{
name: 'settings/updated',
mode: 'emit',
signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void',
jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * Listener failures are contained and logged — a sync throw and an async\n * rejection alike — except `INVARIANT`-coded failures, which rethrow\n * after every listener ran; that rethrow reaches the emitter only from\n * synchronous listeners, so invariant checks on this event must not be\n * async functions.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */',
summary: 'Committed change to one registered namespace\'s resolved value.',
},
{
name: 'skills/change',
mode: 'emit',
@@ -1503,9 +1536,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AskUserQuestionAnswerItem',
declaration: 'export interface AskUserQuestionAnswerItem {\n id: string;\n selected: string[];\n custom?: string;\n}',
},
{
name: 'AskUserQuestionIntent',
declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n};',
},
{
name: 'AskUserQuestionItem',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n intent?: AskUserQuestionIntent;\n}',
},
{
name: 'AskUserQuestionOption',
@@ -2371,6 +2408,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionTitleUserMessage',
declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}',
},
{
name: 'SettingsApplies',
declaration: 'export type SettingsApplies = \'live\' | \'restart\';',
},
{
name: 'SettingsDescriptor',
declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n applies: SettingsApplies;\n}',
},
{
name: 'SettingsNamespace',
declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;',
},
{
name: 'SettingsRegisterOptions',
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n}',
},
{
name: 'SettingsScope',
declaration: 'export interface SettingsScope<T> {\n get(): T;\n watch(callback: (next: T, prev: T) => void | Promise<void>): () => void;\n update(patch: object): Promise<void>;\n replace(section: object): Promise<void>;\n}',
},
{
name: 'SkillCandidate',
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',

View File

@@ -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/fs/tool-fs-search/README.md
README.md: 88a80fb51d7161e6940a3460b7f506575592f9cb
README.zh.md: 9767c545ddc87d323c9fefa43a3145d1a2df5914
README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4
README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60

View File

@@ -5,9 +5,9 @@ English | [中文](README.zh.md)
The **model-facing filesystem discovery tools**`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
// A deployment chooses how over-cap glob pages are selected.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
@@ -20,11 +20,12 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin
## Config
All keys are optional; the defaults are the shipped search caps.
`sampleOverCapGlobResults` is required and has no fallback; deployments choose the over-cap ordering contract explicitly. The remaining keys are optional search caps with the defaults below.
| Key | Default | Meaning |
|---|---|---|
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
| `sampleOverCapGlobResults` | none (required) | `true` samples an over-cap `glob` page across top-level entries; `false` keeps the modification-time-ordered head. When formatted spill succeeds, both modes preserve the complete sorted list in that artifact. |
| `globMaxResults` | `100` | Max paths one `glob` call shows inline (matches Claude Code's `GlobTool` limit). A result within the cap remains complete and modification-time ordered. |
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
@@ -34,14 +35,14 @@ All keys are optional; the defaults are the shipped search caps.
| Tool | Arguments | Behavior |
|---|---|---|
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. |
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
## Errors
@@ -55,10 +56,16 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
##### Glob guidance
##### Glob guidance with `sampleOverCapGlobResults: true`
```markdown
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
```
##### Glob guidance with `sampleOverCapGlobResults: false`
```markdown
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.
```
##### Grep guidance
@@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### Token effect
Fixed guidance cost per request while the tools are registered.
Fixed guidance cost per request while the tools are registered; the required sampling choice selects one glob variant.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
Prefix-stable while the plugin scope, sampling choice, and guidance text are unchanged. Activation, disposal, or changing the choice may invalidate reuse from this prompt section.
### Tool schemas
#### What the model sees
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible.
The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds.
#### Token effect
@@ -93,7 +100,7 @@ Prefix-stable while tool visibility and definitions are unchanged. Registration
#### What the model sees
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. With `sampleOverCapGlobResults: true`, an over-cap `glob` page takes paths round-robin across entries immediately beneath the actual search root, and the footer states the sampled basis and how many top-level entries it reached; when it cannot reach them all, the footer tells the model to narrow `path`. With `false`, the page is the modification-time-ordered head and keeps the plain capped-result footer. A result that fits inline is untouched, and a flat sampled result also keeps the plain footer because its sample equals the modification-time head. The spill artifact always holds the complete list in modification-time order.
#### Token effect
@@ -122,3 +129,4 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
- **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred.

View File

@@ -5,9 +5,9 @@
**面向模型的文件系统发现工具**`glob``grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时本包package探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep就记录警告并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)``ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
```ts ignore-check
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
// A deployment chooses how over-cap glob pages are selected.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
@@ -20,11 +20,12 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
## 配置
所有键均为可选;默认值是随产品交付的搜索上限
`sampleOverCapGlobResults` 是必填项且没有回退值;部署必须显式选择超过上限时的排序契约。其余键是可选的搜索上限,默认值如下
| 键 | 默认值 | 含义 |
|---|---|---|
| `globMaxResults` | `100` | 一次 `glob` 调用内联保留的最大路径数(与 Claude Code 的 `GlobTool` 上限相同);后续路径写入格式化 spill 产物。 |
| `sampleOverCapGlobResults` | 无(必填) | `true` 会在顶层条目之间对超过上限的 `glob` 页面采样;`false` 保留按修改时间排序的前部。格式化 spill 成功时,两种模式都会在该产物中保留完整排序列表。 |
| `globMaxResults` | `100` | 一次 `glob` 调用内联展示的最大路径数(与 Claude Code 的 `GlobTool` 上限相同)。未超过上限的结果保持完整,并按修改时间排序。 |
| `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 |
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
@@ -34,14 +35,14 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| 工具 | 参数 | 行为 |
|---|---|---|
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个路径,按修改时间排序。 |
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 |
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
常规预算不进入面向模型的 schema没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。
## 两类预算、两类产物
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ paths }` 中保留所有已取得路径;`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
## 错误
@@ -55,10 +56,16 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema而不移除其提示词段。
##### Glob 指导
##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导
```markdown
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
```
##### 启用 `sampleOverCapGlobResults: false` 时的 Glob 指导
```markdown
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.
```
##### Grep 指导
@@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### Token 影响
工具注册期间,每个请求支付固定指导成本。
工具注册期间,每个请求支付固定指导成本;必填的采样选项决定采用哪个 glob 变体
#### KV Cache 影响
只要插件作用域和指导文本不变,前缀就保持稳定。启用dispose资源释放可能从该提示词段开始使复用失效。
只要插件作用域、采样选项和指导文本不变,前缀就保持稳定。启用dispose资源释放或更改该选项,可能从该提示词段开始使复用失效。
### 工具 schema
#### 模型看到的内容
当前接口可见时,公开已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search);前提是加载时 `rg` 探测成功
glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;只有加载时 `rg` 探测成功后,这些 schema 才可见
#### Token 影响
@@ -93,7 +100,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### 模型看到的内容
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径footer 会说明采样依据和触达的顶层条目数若无法触达全部条目footer 会要求模型缩小 `path`。设为 `false` 时,页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动扁平的采样结果也沿用普通 footer因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。
#### Token 影响
@@ -122,3 +129,4 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。
- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。
- **schema 只公开一个有界页面**offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。
- **启用采样时,只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。

View File

@@ -3,17 +3,15 @@
* pattern, sorted by modification time. Execution goes through the bash seam
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
* model-facing schema, argument validation, shell-safe command construction,
* result parsing, retention, and formatting; process concerns (defaulting,
* result parsing, inline sampling, and formatting; process concerns (defaulting,
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
*
* @module @deepseek-ai/dsh-tool-fs-search/glob
*/
import type { Context } from 'cordis'
import { sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -41,6 +39,8 @@ export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bz
/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */
export interface GlobToolCaps {
/** Whether over-cap pages are sampled across top-level entries instead of taking the modification-time head. */
sampleOverCapGlobResults: boolean
/** Max paths retained inline; later paths go to the formatted spill file. */
maxResults: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
@@ -99,30 +99,136 @@ export function buildGlobCommand(input: GlobInput): string {
}
/**
* Format the model-facing `glob` result: the retained paths, then — when the
* result was capped — a footer carrying either the formatted-spill recovery
* locator or the could-not-save explanation. The omitted count is a budget fact:
* the search itself completed.
* The inline page of a capped `glob` result, plus how much of the complete
* result's top level it reaches.
*/
export interface GlobSample {
/** Paths to show inline: grouped by top-level entry, modification-time ordered within each group. */
items: string[]
/** Distinct top-level entries the shown paths reach. */
shown: number
/** Distinct top-level entries across the complete result. */
total: number
}
/** Remove the displayed search-root prefix before choosing a top-level group. */
function relativeToSearchRoot(path: string, root: string): string {
if (root === '.') return path.startsWith(`.${sep}`) ? path.slice(2) : path
let rootEnd = root.length
while (rootEnd > 0 && root[rootEnd - 1] === sep) rootEnd -= 1
const trimmedRoot = root.slice(0, rootEnd)
if (trimmedRoot.length === 0) return stripLeadingSeparators(path)
if (path === trimmedRoot) return ''
if (path.startsWith(`${trimmedRoot}${sep}`)) {
return path.slice(trimmedRoot.length + 1)
}
return path
}
/** Strip only separators recognized by the execution platform. */
function stripLeadingSeparators(path: string): string {
let start = 0
while (path[start] === sep) start += 1
return path.slice(start)
}
/**
* The leading path segment of one display path — the top-level entry, relative
* to the search root, that the path sits under. A path with no separator is its
* own top-level entry. Leading separators are stripped first so an absolute path
* (one outside the workdir, which {@link toWorkdirRelative} leaves untouched)
* groups by its first real name instead of collapsing every such path into one
* empty group.
*/
function topLevelSegment(path: string): string {
const trimmed = stripLeadingSeparators(path)
const cut = trimmed.indexOf(sep)
return cut === -1 ? trimmed : trimmed.slice(0, cut)
}
/**
* Choose the inline page of an over-cap result by round-robin across the
* complete result's top-level entries, instead of taking its head.
*
* @param retained - the retention outcome over every discovered path.
* Every top-level entry receives a slot before any receives a second; exhausted
* groups drop out. Group order and order within each group follow `paths`, so a
* flat result reproduces the modification-time head.
*
* @param paths - the complete result, in ripgrep's modification-time order.
* @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`.
* @param root - the search root in the same display-path space as `paths`.
* @returns the page grouped by top-level entry, with the shown/total top-level spread.
*/
export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, root = '.'): GlobSample {
type ActiveGroup = { key: string; items: string[]; index: number; current: string }
const groups = new Map<string, string[]>()
let active: ActiveGroup[] = []
for (const path of paths) {
const key = topLevelSegment(relativeToSearchRoot(path, root))
const group = groups.get(key)
if (group === undefined) {
const items = [path]
groups.set(key, items)
active.push({ key, items, index: 0, current: path })
} else {
group.push(path)
}
}
const taken = new Map<string, string[]>()
let count = 0
while (active.length > 0 && count < maxItems) {
const nextActive: ActiveGroup[] = []
for (const { key, items, index, current } of active) {
if (count >= maxItems) break
count += 1
const bucket = taken.get(key)
if (bucket === undefined) taken.set(key, [current])
else bucket.push(current)
const nextIndex = index + 1
const nextPath = items[nextIndex]
if (nextPath !== undefined) nextActive.push({ key, items, index: nextIndex, current: nextPath })
}
active = nextActive
}
return { items: [...taken.values()].flat(), shown: taken.size, total: groups.size }
}
/**
* Format a capped sampled page and its complete-result recovery path. A flat
* result keeps the plain footer because its sample is the modification-time head.
*
* @param sample - the inline page and its top-level spread.
* @param seen - how many paths the complete result holds; always more than the page.
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
* @returns the model-facing text.
*/
export function formatGlobOutput(retained: RetainedItems<string>, spillRef: SpillRef | undefined): string {
const body = retained.items.join('\n')
if (!retained.truncated) return body
export function formatGlobOutput(sample: GlobSample, seen: number, spillRef: SpillRef | undefined): string {
const basis = sample.total === seen
? '.'
: `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.`
+ (sample.shown < sample.total ? ' Narrow path to inspect a specific subtree.' : '')
return formatGlobPage(sample.items, seen, spillRef, basis)
}
/** Format one bounded page and the recovery path for its complete sorted result. */
function formatGlobPage(items: readonly string[], seen: number, spillRef: SpillRef | undefined, basis: string): string {
const body = items.join('\n')
const recovery = spillRef !== undefined
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
: 'The complete result could not be saved; narrow pattern or path to see more.'
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
return `${body}\n\n(Showing ${items.length} of ${seen} paths${basis} ${recovery})`
}
/** Retain and format one canonical path list for the Native surface. */
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
/** Bound and format one canonical path list for the Native surface relative to its search root. */
function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spillRef?: SpillRef): string {
if (paths.length === 0) return 'No files found'
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
return formatGlobOutput(retainer.finish(), spillRef)
// A result that fits is shown whole, untouched: modification-time order is the
// tool's contract, and over a complete result it is what answers age questions.
if (paths.length <= caps.maxResults) return paths.join('\n')
if (!caps.sampleOverCapGlobResults) {
return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef, '.')
}
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef)
}
/**
@@ -144,19 +250,32 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
*/
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
const overCapGuidance = caps.sampleOverCapGlobResults
? 'while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.'
: 'while a larger one keeps the modification-time-ordered head.'
ctx.systemPrompt.section({
name: 'tool:glob',
order: 103,
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. '
+ `Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, ${overCapGuidance}`,
})
const overCapDescription = caps.sampleOverCapGlobResults
? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries`
: `a larger result returns the first ${caps.maxResults} paths in modification-time order`
const tool = defineTool({
name: 'glob',
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
description: 'Find files whose paths match a glob pattern. Returns matching file paths — never directories — '
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
+ `Returns the first ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`,
+ `Up to ${caps.maxResults} paths come back in modification-time order; ${overCapDescription}, `
+ 'says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.',
parameters: {
pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js").' },
pattern: {
type: 'string',
required: true,
description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js"). '
+ 'A pattern with no "/" matches the basename at any depth, so "*" and "*.ts" both search the whole tree; include a separator to anchor the depth.',
},
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
},
timeoutMs: caps.timeoutMs,
@@ -165,15 +284,17 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
type: 'object',
additionalProperties: false,
properties: {
root: { type: 'string', required: true },
paths: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }],
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return { paths: [] }
const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir)
if (run.noMatches) return { root, paths: [] }
const all: string[] = []
for (const line of run.stdout.split('\n')) {
@@ -181,7 +302,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
const displayPath = toWorkdirRelative(line, run.workdir)
all.push(displayPath)
}
return { paths: all }
return { root, paths: all }
},
presentCall: presentGlobCall,
})
@@ -189,14 +310,14 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { root: string; paths: string[] } | undefined
if (value === undefined) return decision
const paths = value.paths
if (paths.length <= caps.maxResults) return decision
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
return {
kind: 'accept',
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
content: [{ type: 'text', text: renderGlobPaths(paths, caps, value.root, spillRef) }],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})

View File

@@ -33,8 +33,8 @@ import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts'
export type { GlobInput, GlobToolCaps } from './glob.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts'
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
export {
GREP_MAX_LINE_BYTES,
GREP_MAX_MATCHES,
@@ -58,8 +58,10 @@ export const name = 'tool-fs-search'
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
export const inject = ['tools', 'systemPrompt', 'bash']
/** Plugin config (all optional — `Config` supplies the defaults). */
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
export interface Config {
/** Whether an over-cap `glob` page is sampled across top-level entries instead of taking the modification-time head. */
sampleOverCapGlobResults: boolean
/** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */
globMaxResults?: number
/** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */
@@ -73,6 +75,7 @@ export interface Config {
}
export const Config: z<Config> = z.object({
sampleOverCapGlobResults: z.boolean().required(),
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
@@ -137,6 +140,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
return
}
applyGlobTool(ctx, {
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
maxResults: resolved.globMaxResults,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,

View File

@@ -64,7 +64,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch)
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
})
afterEach(async () => {

View File

@@ -82,7 +82,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
// A collapsed export shape (dropped inject) would throw "without inject" here.
const fiber = await ctx.plugin(unwrapped)
const fiber = await ctx.plugin(unwrapped, { sampleOverCapGlobResults: true })
expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep']))
await fiber.dispose()
})

View File

@@ -12,7 +12,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join } from 'node:path'
import { join, sep } from 'node:path'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
@@ -29,6 +29,7 @@ import {
presentGlobCall,
presentGrepCall,
previewLine,
sampleAcrossTopLevel,
toWorkdirRelative,
} from '@deepseek-ai/dsh-tool-fs-search'
@@ -110,12 +111,14 @@ class FakeSpill extends SpillStore {
}
interface SetupOptions {
config?: ToolFsSearch.Config
config?: Partial<ToolFsSearch.Config>
spill?: boolean
probeError?: Error
probeResult?: BashRunResult
}
const DEFAULT_CONFIG = { sampleOverCapGlobResults: true } satisfies ToolFsSearch.Config
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
const warnings: string[] = []
@@ -127,7 +130,7 @@ async function setup(options: SetupOptions = {}) {
if (options.probeResult) bash.probeResult = options.probeResult
if (options.probeError) bash.probeError = options.probeError
if (options.spill === true) await ctx.plugin(FakeSpill)
const fiber = await ctx.plugin(ToolFsSearch, options.config)
const fiber = await ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...options.config })
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
return { ctx, bash, spill, fiber, warnings }
}
@@ -184,6 +187,10 @@ describe('registration', () => {
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Use the glob tool')
expect(prompt).toContain('Use the grep tool')
expect(prompt).toContain('sampled across top-level entries')
expect(prompt).not.toContain('sampled across top-level directories')
const glob = ctx.tools.schemas().find(schema => schema.name === 'glob')
expect(glob?.description).toContain('sampled across top-level entries')
})
it('does not register glob or grep when the bash executor cannot find rg', async () => {
@@ -211,7 +218,7 @@ describe('registration', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolFsSearch) // no bash executor
await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no bash executor
expect(ctx.tools.schemas()).toHaveLength(0)
})
@@ -236,9 +243,27 @@ describe('registration', () => {
expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000)
expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000)
})
it('describes the modification-time head when over-cap sampling is disabled', async () => {
const { ctx } = await setup({ config: { sampleOverCapGlobResults: false } })
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('a larger one keeps the modification-time-ordered head')
expect(prompt).not.toContain('sampled across top-level entries')
const glob = ctx.tools.schemas().find(schema => schema.name === 'glob')
expect(glob?.description).toContain('a larger result returns the first 100 paths in modification-time order')
expect(glob?.description).not.toContain('sampled across top-level entries')
})
})
describe('config validation', () => {
it('requires an explicit over-cap glob sampling choice', () => {
expect(() => new ToolFsSearch.Config()).toThrow(/sampleOverCapGlobResults/)
expect(new ToolFsSearch.Config({ sampleOverCapGlobResults: false })).toMatchObject({
sampleOverCapGlobResults: false,
globMaxResults: 100,
})
})
it.each([
['globMaxResults', { globMaxResults: 0 }],
['grepMaxMatches', { grepMaxMatches: -1 }],
@@ -250,7 +275,7 @@ describe('config validation', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeBash)
await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
})
})
@@ -498,13 +523,100 @@ describe('raw output acquisition', () => {
})
})
describe('cross-directory sampling', () => {
it('gives every top-level entry a slot before any entry gets a second', () => {
const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f']
// The head of 3 would be all `v/`; the sample reaches all three entries.
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'], shown: 3, total: 3 })
// Extra slots go round again — to the only entry with paths left — and the
// page stays grouped by entry rather than interleaved.
expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'], shown: 3, total: 3 })
})
it('hands an exhausted entry the remaining slots go to entries that still have paths', () => {
const paths = ['solo/a', 'many/b', 'many/c', 'many/d']
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 })
})
it('does not rescan exhausted entries while filling a skewed page', () => {
const singletonCount = 12_500
const paths = [
...Array.from({ length: singletonCount }, (_, index) => `group-${index}/only`),
...Array.from({ length: singletonCount }, (_, index) => `late/${index}`),
]
expect(sampleAcrossTopLevel(paths, paths.length - 1)).toMatchObject({
shown: singletonCount + 1,
total: singletonCount + 1,
items: { length: paths.length - 1 },
})
}, 500)
it('reports the entries it could not reach when the page is smaller than the top level', () => {
const paths = ['a/1', 'b/1', 'c/1', 'd/1']
expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'], shown: 2, total: 4 })
})
it('groups an absolute path by its first real name, not by its empty root segment', () => {
// Paths outside the workdir stay absolute; without stripping the leading
// separator every one of them would collapse into a single empty group.
expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'], 2))
.toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 })
})
it('reproduces the modification-time-ordered head for a flat result', () => {
expect(sampleAcrossTopLevel(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ items: ['a.ts', 'b.ts'], shown: 2, total: 3 })
})
it('groups paths relative to an explicit search root', () => {
expect(sampleAcrossTopLevel([
'workspace/vendor/a.ts',
'workspace/vendor/b.ts',
'workspace/source/c.ts',
'workspace/guides/d.md',
], 3, 'workspace')).toEqual({
items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'],
shown: 3,
total: 3,
})
expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.'))
.toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/'))
.toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 })
const rooted = [
['root', 'a', 'one'].join(sep),
['root', 'a', 'two'].join(sep),
['root', 'b', 'three'].join(sep),
]
expect(sampleAcrossTopLevel(rooted, 2, 'root'))
.toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src'))
.toEqual({ items: ['other/a.ts'], shown: 1, total: 1 })
expect(sampleAcrossTopLevel(['src'], 1, 'src'))
.toEqual({ items: ['src'], shown: 1, total: 1 })
})
it.skipIf(process.platform === 'win32')('treats POSIX backslashes as filename characters', () => {
const paths = ['old\\one', 'old\\two', 'src/a']
expect(sampleAcrossTopLevel(paths, 2)).toEqual({
items: ['old\\one', 'old\\two'],
shown: 2,
total: 3,
})
})
it('handles more top-level groups than the JavaScript argument limit', () => {
const paths = Array.from({ length: 125_000 }, (_, index) => `dir-${index}/file.txt`)
expect(sampleAcrossTopLevel(paths, 100)).toMatchObject({ shown: 100, total: 125_000 })
})
})
describe('glob results', () => {
it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(result.value).toEqual({ root: '.', paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
})
@@ -534,7 +646,7 @@ describe('glob results', () => {
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]).toMatchObject({
@@ -547,6 +659,79 @@ describe('glob results', () => {
expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }])
})
it('samples an over-cap result across top-level entries instead of taking its head', async () => {
// The shipped failure: `*` matches the whole tree, mtime order puts one
// freshly-unpacked subtree first, and a head-of-3 reads like the entire
// workspace. The sample reaches every top-level entry instead.
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n'
+ '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched '
+ 'instead of taken in modification-time order. Narrow path to inspect a specific subtree. '
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
})
it('keeps the modification-time head when over-cap sampling is disabled', async () => {
const { ctx, bash } = await setup({
config: { globMaxResults: 3, sampleOverCapGlobResults: false },
})
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n'))
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('vendor/a.ts\nvendor/b.ts\nvendor/c.ts\n\n'
+ '(Showing 3 of 5 paths. The complete result could not be saved; narrow pattern or path to see more.)')
})
it('samples relative to the explicit search root instead of its workdir prefix', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult([
'workspace/vendor/a.ts',
'workspace/vendor/b.ts',
'workspace/source/c.ts',
'workspace/guides/d.md',
].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') })
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
})
it('samples relative to an absolute search root after workdir display conversion', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult([
'/w/workspace/vendor/a.ts',
'/w/workspace/vendor/b.ts',
'/w/workspace/source/c.ts',
'/w/workspace/guides/d.md',
].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') })
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
})
it('drops the narrowing hint when the sample reaches every top-level entry', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n'))
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n'
+ '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched '
+ 'instead of taken in modification-time order. '
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
})
it('keeps modification-time order untouched when the whole result fits', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 4 } })
bash.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n')
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts')
})
it('keeps the plain footer for a flat result, where the sample is the modification-time head', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('a.ts\nb.ts\n\n(Showing 2 of 3 paths. The complete result could not be saved; narrow pattern or path to see more.)')
})
it('does not create a spill file when the result fits inline', async () => {
const { ctx, bash, spill } = await setup({ spill: true })
bash.handler = () => runResult('a.ts\nb.ts\n')
@@ -559,14 +744,14 @@ describe('glob results', () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
value: { paths: ['replacement-a.ts', 'replacement-b.ts'] },
value: { root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] },
}))
bash.handler = () => runResult('old-a.ts\nold-b.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob replacement success')
expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(text(result)).toContain('replacement-a.ts')
expect(text(result)).not.toContain('old-a.ts')
expect(spill?.saves).toHaveLength(0)
@@ -580,7 +765,7 @@ describe('glob results', () => {
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)')
expect(spill?.saves).toHaveLength(0)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/README.md
README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982
README.zh.md: 46516d187321029ed739d8c071f246bc1755b125
README.md: 391adb7009a01d1ec95c8dcb809e8a2065aa0b31
README.zh.md: 7fc730ed9ec3a067589b277733eb5bb2c42f8b4e

View File

@@ -11,5 +11,6 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
| `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` |
| `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) |
| `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) |
| `directory-picker-auto/` | Adaptive chooser: resolves the host's situation once at boot (bind host, SSH, display) and mounts the matching dual-face backend as an in-memory Loader entry | (mounts a backend row) |
`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.

View File

@@ -11,5 +11,6 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承
| `directory-picker/` | 工作区目录选择 seam网关的 picker RPC 委托的可辨识 `native``browse` 能力 | `ctx.directoryPicker` |
| `directory-picker-native/` | 双面原生交互OS 选择器后端osascriptPowerShellZenity+KDialog仅宿主屏幕可用+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker` |
| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker` |
| `directory-picker-auto/` | 自适应选择器启动时一次性判定宿主处境绑定宿主、SSH、显示并把匹配的双面后端挂载为内存中的 Loader 条目 | (挂载一个后端行) |
`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 8f9deb6add7d30bf1609cc7febcb1febafe392c1
README.zh.md: 399d45208b6d3f4152c27556523b6944432bec66
README.md: 3ec21f90a495fe42e40c4407e0a81faa34a1e427
README.zh.md: 5bcd310c23c35b851216176d69b13965dd2e1c3e

View File

@@ -16,6 +16,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
@@ -43,7 +45,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).

View File

@@ -16,7 +16,9 @@
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方模型推理reasoning目标以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方模型推理reasoning目标及谱系再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
@@ -43,7 +45,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
## 已知限制与延期工作
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**协议形状POST `/api/respond``RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
- **预留 seam 不进入 `RpcMethodMap`**`session.fork``prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **预留 seam 不进入 `RpcMethodMap`**`prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Noteagent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。

View File

@@ -1148,6 +1148,75 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async fork(request) {
const { sessionId, atSeq } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const source = found.agent.session
const events = source.events
// An in-log anchor belongs to the turn containing it and must never
// clip backward to an earlier completed turn. Omitted and past-end
// anchors retain the last-completed-turn shortcut.
const lastSeq = events.at(-1)?.seq ?? -1
const anchoredBoundary = atSeq === undefined
? undefined
: events.find(e => e.type === 'turn/end' && e.seq >= atSeq)
const boundary = anchoredBoundary
?? (atSeq === undefined || atSeq > lastSeq
? events.findLast(e => e.type === 'turn/end')
: undefined)
if (boundary === undefined) {
return err(request, {
code: 'fork-unavailable',
message: atSeq !== undefined && atSeq <= lastSeq
? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}`
: `session "${sessionId}" has no completed turn to fork from`,
details: { sessionId },
})
}
// Extend the cut through trailing out-of-band appends (session/title,
// injections) up to the next turn/start: they are standalone events, so
// the seed stays balanced, and the child inherits a title generated
// right after the boundary turn.
let cut = boundary.seq + 1
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
const childId = `session-${randomUUID()}` as SessionId
try {
await ctx.agents.create({
sessionId: childId,
seed: events.slice(0, cut),
meta: {
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
parentSession: source.id,
seedLength: cut,
},
agentOptions,
setup: installTarget,
})
} catch (error: unknown) {
return err(request, {
code: 'internal',
message: `failed to fork session "${sessionId}": ${String(error)}`,
details: {},
})
}
// Keep the child in the source's Workspace so the list nests it under
// its parent; the child is already published if the attach fails.
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
if (workspace !== undefined) {
try {
await workspace.attachSession(childId)
} catch (error: unknown) {
return err(request, {
code: 'workspace-attach-failed',
message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
details: { sessionId: childId, workspaceId: workspace.id },
})
}
}
return ok(request, { sessionId: childId })
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)

View File

@@ -23,6 +23,11 @@ export const askUserQuestionItemSchema = z.object({
detail: z.string().optional(),
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
multiSelect: z.boolean().optional(),
// Presentation intent: a tagged union on the wire, so an unknown tag is a
// rejected frame rather than a silently generic render.
intent: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('plan-review'), approve: z.string() }),
]).optional(),
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
/** Unified message envelope carried by transient queue frames. */

View File

@@ -24,6 +24,7 @@ export interface RpcMethodMap {
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.rename': SessionsApi['rename']
'session.fork': SessionsApi['fork']
'session.prompt': SessionsApi['prompt']
'session.updateQueue': SessionsApi['updateQueue']
'session.cancel': SessionsApi['cancel']

View File

@@ -51,6 +51,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -51,6 +51,7 @@ export interface RpcErrorDetailsMap {
/** A leading-/ prompt named no registered command; the message names the token. */
'unknown-command': {}
'title-invalid': { sessionId: SessionId }
'fork-unavailable': { sessionId: SessionId }
'internal': {}
}

View File

@@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({
seq: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
/** session.fork request payload (atSeq anchors the completed-turn cut). */
export const sessionForkRequestSchema = z.object({
sessionId: sessionIdSchema,
atSeq: z.number().int().nonnegative().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.fork'>>>
/** session.fork response value (the child session id). */
export const sessionForkValueSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.fork'>>>
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
export const sessionHistoryRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -238,6 +238,21 @@ export interface SessionsApi {
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
*/
/**
* Forks a new session from a completed-turn prefix of the source. `atSeq`
* anchors the cut: the boundary is the first `turn/end` at or after it
* (a message's fork button passes the message seq, so the fork includes
* that whole turn); a boundary past the log end, or an omitted `atSeq`,
* falls back to the source's last completed turn. An in-log anchor whose
* turn is still open fails with `fork-unavailable` instead of clipping to
* an earlier turn. The child inherits the source cwd, latest logged model
* target, workspace attachment, and `parentSessionId` lineage; the seed
* prefix carries the source title.
*/
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>

View File

@@ -20,6 +20,7 @@ import {
import {
sessionCancelValueSchema,
sessionCreateValueSchema,
sessionForkValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionModelsValueSchema,
@@ -69,6 +70,7 @@ export interface IApiClient {
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
@@ -121,6 +123,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.rename': sessionRenameValueSchema,
'session.fork': sessionForkValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
@@ -334,6 +337,7 @@ export abstract class AbstractApiClient implements IApiClient {
models: (payload, signal) => this.callUnary('session.models', payload, signal),
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),

View File

@@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
import {
sessionCancelRequestSchema,
sessionCreateRequestSchema,
sessionForkRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionModelsRequestSchema,
@@ -71,6 +72,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },

View File

@@ -0,0 +1,163 @@
/** Session-fork boundaries, lineage, and inherited model routing. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
}
async function composed(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('workspace', { list: () => [] } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
...options.seed === undefined ? {} : { seed: [...options.seed] },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = {} as Agent
const agentCtx = ownerCtx.extend({ agent })
Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
await options.setup?.(agentCtx)
ctx.agents.register(agent)
return { agent, dispose: () => Promise.resolve() }
},
resume: () => Promise.reject(new Error('fork test sources are live')),
})
return ctx
}
function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
if (openTail) {
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
}
const api = (ctx: Context) => createApiProxy(ctx, {
provider: 'default-provider',
model: 'default-model',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
describe('sessions.fork', () => {
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-source', 2)
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
const child = ctx.sessions.get(response.result.value.sessionId)
expect(child?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end', 'session/end-seed',
])
expect(child?.header.parentSession).toBe(source.id)
expect(child?.header.cwd).toBe('/proj')
await ctx.fiber.dispose()
})
it('uses the last completed turn only for omitted and past-end anchors', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, true)
const proxy = api(ctx)
const expectedTypes = [
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
]
const omitted = await proxy.sessions.fork(request({ sessionId: source.id }))
expect(omitted.result.ok).toBe(true)
if (omitted.result.ok) {
expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
}
const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 }))
expect(pastEnd.result.ok).toBe(true)
if (pastEnd.result.ok) {
expect(ctx.sessions.get(pastEnd.result.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
}
await ctx.fiber.dispose()
})
it('rejects an in-log anchor whose turn is still open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-open', 1, true)
const anchor = source.events.at(-1)?.seq ?? 0
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.result).toMatchObject({
ok: false,
error: { code: 'fork-unavailable', details: { sessionId: source.id } },
})
if (!response.result.ok) expect(response.result.error.message).toMatch(/has not completed/)
await ctx.fiber.dispose()
})
it('installs the latest logged model target before the child can run', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-routed', 1)
source.append('request/header', {
header: {
config: {
provider: 'inherited-provider',
model: 'inherited-model',
reasoningEffort: ReasoningEffortId('high'),
},
},
reason: 'initial',
})
const response = await api(ctx).sessions.fork(request({ sessionId: source.id }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
const child = ctx.agents.get(response.result.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
const assembly = await child.ctx.systemPrompt.assemble()
expect(assembly.variables).toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
})
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
await expect(agentEvents(child.ctx, child).waterfall(
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback),
)).resolves.toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
reasoningEffort: 'high',
})
await ctx.fiber.dispose()
})
})

View File

@@ -47,6 +47,7 @@ function scriptedApi(overrides: {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
rename: r => ok(r, { title: 'renamed', seq: 0 }),
fork: r => ok(r, { sessionId: sid('s-fork') }),
prompt: r => ok(r, { accepted: true as const }),
updateQueue: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
@@ -110,6 +111,21 @@ describe('unary round trip', () => {
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
})
it('routes session fork with its optional cut anchor through the wire', async () => {
let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
const api = scriptedApi({
sessions: {
fork: (request) => {
seen = request
return ok(request, { sessionId: sid('s-child') })
},
},
})
const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 })
expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 })
expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } })
})
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
const api = scriptedApi()
const c = client(api)

View File

@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async rename(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
},
async fork(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } }
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},

View File

@@ -399,6 +399,17 @@ describe('events frame schemas', () => {
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
})
it('carries a question presentation intent through, and rejects an unknown one', () => {
const intent = { kind: 'plan-review', approve: 'Approve' }
expect(askUserQuestionItemSchema.parse({
id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent,
}).intent).toEqual(intent)
// An unrecognised tag is a rejected frame, not a silently generic render.
for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) {
expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).toThrow()
}
})
it('rejects a queue snapshot with malformed items', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()

View 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/host/directory-picker-auto/README.md
README.md: 10d1784590b79fdfef3cf6683d389182cd8437b6
README.zh.md: 86ec9f2c3a87557e86038ce7d3f89887c5bb3546

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-host-directory-picker-auto
English | [中文](README.zh.md)
The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it.
Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
## Model Experience
None, as the chooser only composes the GUI host's directory selection; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments.
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once.

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-host-directory-picker-auto
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op。由于后端以普通条目的形式到达其 browser half 被 client 模块表发现的方式与配置行完全相同因此对判定出的选择seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。
判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION``SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上以及可服务的显示会话——darwinwin32 上视为存在linux 上要求 `DISPLAY``WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwinwin32linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native``-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。
## 模型体验
无。该选择器仅组合 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
#### KV 缓存影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenitykdialogshell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。

View File

@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-auto",
"description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host",
"version": "0.0.1",
"private": true,
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-host-directory-picker-browse": "^0.0.1",
"@deepseek-ai/dsh-host-directory-picker-native": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,71 @@
/**
* Adaptive chooser of the directory-picker seam: resolves the host's
* situation once at boot (bind host, SSH launch, display session, Linux
* chooser binary) and mounts the matching dual-face backend — `-native` or
* `-browse` — as a real Loader entry in the in-memory root tree. Because the
* backend arrives as an ordinary entry, its browser half is discovered
* exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
* invariant holds for the resolved choice; pinning an interaction remains
* composing that backend row directly instead of this one.
* @module @deepseek-ai/dsh-host-directory-picker-auto
*/
import type { Context } from 'cordis'
// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
import type {} from '@cordisjs/plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
import type { DirectoryPickerBackendKind } from './resolve.ts'
import { resolveDirectoryPickerBackend } from './resolve.ts'
export { canExecute, hasLinuxChooserBinary } from './probe.ts'
export type { DirectoryPickerBackendKind, DirectoryPickerEnv, DirectoryPickerHostFacts } from './resolve.ts'
export { resolveDirectoryPickerBackend } from './resolve.ts'
/** Cordis plugin name. */
export const name = 'directory-picker-auto'
/** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */
export const inject = ['httpServer', 'loader']
/**
* Backend package per resolved kind — fixed composition vocabulary, not a
* tunable. Exported because the reference is a runtime string the static
* config gate cannot see in a yml row: `verify-cordis-config` requires every
* app composing this chooser to declare both values as dependencies.
*/
export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
native: '@deepseek-ai/dsh-host-directory-picker-native',
browse: '@deepseek-ai/dsh-host-directory-picker-browse',
}
/**
* Resolve the backend from one boot-time sample and mount it as a Loader
* entry; the effect's disposer removes the entry and joins the backend
* fiber's teardown, so unloading this plugin returns only after both faces
* of the mounted backend (and their dependents) quiesced.
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
*/
export async function apply(ctx: Context): Promise<void> {
const backend = resolveDirectoryPickerBackend({
bindHost: ctx.httpServer.host,
platform: process.platform,
env: process.env,
linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute),
})
await ctx.effect(async () => {
// Root-tree create: the Loader root is in-memory (write() is a no-op), so
// the mounted row can never be persisted back into a config file.
const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
return async () => {
// Tree teardown (group.stop) can have removed the entry already;
// nothing is left to unmount or await then.
const entry = ctx.loader.store[id]
if (entry === undefined) return
const fiber = entry.fiber
ctx.loader.remove(id)
// remove() only starts the fiber's dispose; join it so the chooser's
// unload signals completion only after the backend quiesced.
await fiber?.dispose()
}
}, 'directory-picker-auto: backend entry')
}

View File

@@ -0,0 +1,25 @@
/**
* Package-owned invariant companion for the adaptive directory-picker chooser.
* @module @deepseek-ai/dsh-host-directory-picker-auto/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto'
/** Cordis companion plugin name. */
export const name = 'host-directory-picker-auto-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the sole effect is one boot-time Loader-entry mount owned by the plugin fiber; the store is authoritative. */
const install: InvariantInstaller = () => {}
/**
* Register the adaptive directory-picker invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,44 @@
/**
* PATH probe for the native backend's Linux chooser binaries: one boot-time
* sampled fact for the resolver, so an attended Linux host without
* zenity/kdialog keeps the working `browse` interaction instead of a backend
* whose every pick fails.
* @module @deepseek-ai/dsh-host-directory-picker-auto/probe
*/
import { accessSync, constants } from 'node:fs'
import { delimiter, join } from 'node:path'
/** The chooser binaries the native backend can drive on Linux (zenity, KDialog fallback). */
const LINUX_CHOOSER_BINARIES = ['zenity', 'kdialog'] as const
/**
* Whether the current process may execute the candidate path.
* @param candidate - absolute or PATH-joined file path.
* @returns true only for an existing executable file.
*/
export function canExecute(candidate: string): boolean {
try {
accessSync(candidate, constants.X_OK)
} catch {
// Absent or non-executable candidate — the only signals accessSync(X_OK) emits.
return false
}
return true
}
/**
* Scan a PATH value for one of the native backend's Linux chooser binaries.
* @param pathValue - the `PATH` environment value (absent or empty scans nothing).
* @param isExecutable - executability predicate ({@link canExecute} in production; injected for deterministic tests).
* @returns whether any PATH directory holds an executable chooser binary.
*/
export function hasLinuxChooserBinary(pathValue: string | undefined, isExecutable: (candidate: string) => boolean): boolean {
for (const dir of (pathValue ?? '').split(delimiter)) {
if (dir === '') continue
for (const name of LINUX_CHOOSER_BINARIES) {
if (isExecutable(join(dir, name))) return true
}
}
return false
}

View File

@@ -0,0 +1,53 @@
/**
* Boot-time backend resolution for the adaptive directory-picker composition:
* one pure decision from sampled host facts to a concrete backend kind. The
* caller samples exactly once per boot, so the mounted capability stays
* stable for the service lifetime as the seam requires.
* @module @deepseek-ai/dsh-host-directory-picker-auto/resolve
*/
import type { Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver'
/** Concrete interaction backend the resolver chooses between. */
export type DirectoryPickerBackendKind = 'native' | 'browse'
/** Environment keys the resolution reads (a `process.env` subset). */
export type DirectoryPickerEnv = Readonly<
Partial<Record<'SSH_CONNECTION' | 'SSH_TTY' | 'DISPLAY' | 'WAYLAND_DISPLAY', string>>
>
/** Host facts the backend choice is a pure function of, sampled once at boot. */
export interface DirectoryPickerHostFacts {
/** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */
bindHost: HttpServerConfig['host']
/** Host process platform. */
platform: NodeJS.Platform
/** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */
env: DirectoryPickerEnv
/** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */
linuxChooser: boolean
}
/** An env value counts only when set and non-blank (an empty export is "unset" by shell convention). */
const present = (value: string | undefined): boolean => value !== undefined && value !== ''
/**
* Resolve which backend serves this boot. `native` requires every signal that
* the operator can see the host display and the native backend can serve it:
* a loopback-only bind (an all-interfaces bind admits remote browsers no OS
* chooser can reach), no SSH launch (under SSH port-forwarding the chooser
* would open on the unattended server), and a servable display session —
* assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a
* chooser binary on linux, and never true elsewhere (the native backend
* drives exactly darwin/win32/linux). Anything ambiguous resolves to
* `browse`, which works everywhere.
* @param facts - the sampled host facts.
* @returns the backend kind to mount.
*/
export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind {
if (facts.bindHost !== '127.0.0.1') return 'browse'
if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse'
if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse'
}

View File

@@ -0,0 +1,176 @@
/**
* REAL-composition coverage: a test-only cordis.yml booted through the
* vendored Loader mounts the webserver row plus the adaptive chooser, and the
* assertions observe the durable outcome — which backend entry the chooser
* mounted into the Loader store, the capability the seam then serves, and
* that disposing the chooser removes the mounted entry again (HMR safety),
* joining the backend's own teardown before the disposer settles.
*/
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import HttpServer from '@deepseek-ai/dsh-host-webserver'
import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'
import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
import * as DirectoryPickerAuto from '../src/index.ts'
const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
let root: string | undefined
let fakeBin: string | undefined
let context: Context | undefined
afterEach(async () => {
vi.unstubAllEnvs()
await context?.fiber.dispose()
context = undefined
for (const dir of [root, fakeBin]) {
// maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late
// file handle) that can otherwise race the recursive scan into ENOTEMPTY.
if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
}
root = undefined
fakeBin = undefined
})
/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
const dist = join(root, 'dist')
mkdirSync(dist)
const distIndex = join(dist, 'index.html')
await writeFile(distIndex, '<head></head><body>shell</body>')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
` host: '${bindHost}'`,
' port: 0',
` distIndex: '${distIndex}'`,
`- name: '${AUTO}'`,
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-host-webserver', HttpServer],
[AUTO, DirectoryPickerAuto],
[NATIVE, NativeDirectoryPicker],
[BROWSE, BrowseDirectoryPicker],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return { ctx: context, configPath }
}
/** Entry names currently present in the loader store (root tree plus subtrees). */
function entryNames(ctx: Context): string[] {
return [...ctx.loader.entries()].map(entry => entry.options.name)
}
/**
* Force every signal of an attended host on any platform: no SSH launch, a
* display, and a PATH holding one executable chooser binary so the real
* probe resolves identically on hosts with and without zenity/kdialog.
*/
function stubAttendedHost(): void {
fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-'))
const zenity = join(fakeBin, 'zenity')
writeFileSync(zenity, '#!/bin/sh\n')
chmodSync(zenity, 0o755)
vi.stubEnv('PATH', fakeBin)
vi.stubEnv('SSH_CONNECTION', '')
vi.stubEnv('SSH_TTY', '')
vi.stubEnv('DISPLAY', ':0')
}
describe('real Loader composition', () => {
// The 60s budget covers this file's static imports (webserver plus both
// backend node halves through tsx), which dominate on cold caches; the
// Loader itself resolves nothing here — `loader.internal` is a module map.
it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx, configPath } = await loadComposition('127.0.0.1')
const unloaded = [...ctx.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(entryNames(ctx)).toContain(NATIVE)
expect(entryNames(ctx)).not.toContain(BROWSE)
const picker = ctx.get('directoryPicker') as DirectoryPicker
expect(picker.capability().kind).toBe('native')
// The mounted row lives in the Loader's in-memory root tree only — the
// booted config file must never gain the resolved backend row.
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
// HMR safety: disposing the chooser's fiber removes the entry it created,
// and the disposer joins the backend's teardown — the service is gone the
// moment dispose() settles, with no further loader await.
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
await autoEntry.fiber!.dispose()
expect(entryNames(ctx)).not.toContain(NATIVE)
expect(ctx.get('directoryPicker')).toBeUndefined()
// Self-disposing an include-tree entry persists `disabled: true` (loader
// behavior, not the chooser's); await that debounced write so it cannot
// race the temp-dir removal, and pin that the persisted row is the
// chooser itself — the resolved backend still never reaches the file.
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
})
it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
stubAttendedHost()
vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22')
const { ctx } = await loadComposition('127.0.0.1')
expect(entryNames(ctx)).toContain(BROWSE)
expect(entryNames(ctx)).not.toContain(NATIVE)
const picker = ctx.get('directoryPicker') as DirectoryPicker
expect(picker.capability().kind).toBe('browse')
})
it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx } = await loadComposition('0.0.0.0')
expect(entryNames(ctx)).toContain(BROWSE)
expect(entryNames(ctx)).not.toContain(NATIVE)
})
it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx, configPath } = await loadComposition('127.0.0.1')
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
ctx.loader.remove(backendEntry.id)
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
expect(entryNames(ctx)).not.toContain(NATIVE)
// Same self-dispose persistence as above: let the write land before teardown.
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
})
})

View File

@@ -0,0 +1,91 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { canExecute, hasLinuxChooserBinary } from '../src/probe.ts'
import { resolveDirectoryPickerBackend } from '../src/resolve.ts'
import type { DirectoryPickerHostFacts } from '../src/resolve.ts'
/** Baseline facts that resolve to `native`; each case overrides one signal (darwin never consults `linuxChooser`). */
const attended: DirectoryPickerHostFacts = {
bindHost: '127.0.0.1',
platform: 'darwin',
env: {},
linuxChooser: false,
}
describe('resolveDirectoryPickerBackend', () => {
it('resolves native for a loopback bind on a display platform', () => {
expect(resolveDirectoryPickerBackend(attended)).toBe('native')
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'win32' })).toBe('native')
})
it('resolves browse for an all-interfaces bind regardless of other signals', () => {
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse')
})
it('resolves browse under an SSH launch (either env marker)', () => {
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '10.0.0.2 55 10.0.0.9 22' } })).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse')
})
it('requires a display session and a chooser binary on linux', () => {
const linux: DirectoryPickerHostFacts = { ...attended, platform: 'linux', linuxChooser: true }
expect(resolveDirectoryPickerBackend(linux)).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' } })).toBe('native')
expect(resolveDirectoryPickerBackend({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native')
expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' }, linuxChooser: false })).toBe('browse')
})
it('resolves browse on platforms the native backend cannot serve, display or not', () => {
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'freebsd', env: { DISPLAY: ':0' }, linuxChooser: true })).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'openbsd', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('browse')
})
it('treats blank env exports as unset', () => {
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native')
expect(resolveDirectoryPickerBackend({
...attended, platform: 'linux', linuxChooser: true, env: { DISPLAY: '', WAYLAND_DISPLAY: '' },
})).toBe('browse')
})
})
let probeRoot: string | undefined
afterEach(() => {
if (probeRoot !== undefined) rmSync(probeRoot, { recursive: true, force: true })
probeRoot = undefined
})
describe('hasLinuxChooserBinary', () => {
it('finds a chooser binary in any PATH segment, skipping empty segments', () => {
const seen: string[] = []
const path = ['', '/opt/none', '/usr/local/bin'].join(delimiter)
const found = hasLinuxChooserBinary(path, (candidate) => {
seen.push(candidate)
return candidate === join('/usr/local/bin', 'kdialog')
})
expect(found).toBe(true)
expect(seen).toEqual([
join('/opt/none', 'zenity'), join('/opt/none', 'kdialog'),
join('/usr/local/bin', 'zenity'), join('/usr/local/bin', 'kdialog'),
])
})
it('reports absence when no segment holds a chooser binary', () => {
expect(hasLinuxChooserBinary(['/a', '/b'].join(delimiter), () => false)).toBe(false)
expect(hasLinuxChooserBinary('', () => true)).toBe(false)
expect(hasLinuxChooserBinary(undefined, () => true)).toBe(false)
})
})
describe('canExecute', () => {
it('accepts an executable file and rejects an absent one', () => {
probeRoot = mkdtempSync(join(tmpdir(), 'dsh-picker-probe-'))
const binary = join(probeRoot, 'zenity')
writeFileSync(binary, '#!/bin/sh\n')
chmodSync(binary, 0o755)
expect(canExecute(binary)).toBe(true)
expect(canExecute(join(probeRoot, 'kdialog'))).toBe(false)
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../webserver"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md
README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f
README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d
README.md: 3749b238b56578ec68610bc13550760aa084bad6
README.zh.md: 488da5129ec211c2a064156c22a9d0abf04d99be

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together.
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself.
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker``ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker``ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable``directory-exists``directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/webserver/README.md
README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0
README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc
README.md: ace8c09e43dd8544a28d300f97b04610be78bc69
README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer``register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer``register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.

Some files were not shown because too many files have changed in this diff Show More