Merge remote-tracking branch 'origin/master' into feature/cordis-temporary-tools
This commit is contained in:
@@ -750,6 +750,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
delete: (request) => {
|
||||
const { workspaceId } = request.payload
|
||||
const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
||||
if (index === -1) {
|
||||
return err(request, {
|
||||
code: 'workspace-not-found',
|
||||
message: `no workspace ${workspaceId}`,
|
||||
details: { workspaceId },
|
||||
})
|
||||
}
|
||||
workspaces.splice(index, 1)
|
||||
emitHost({ type: 'host/workspace-removed', workspaceId })
|
||||
return ok(request, { deleted: true as const })
|
||||
},
|
||||
insertSessionBefore: (request) => {
|
||||
const { workspaceId, sessionId, beforeSessionId } = request.payload
|
||||
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
|
||||
@@ -941,6 +955,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
case 'workspace.delete': return this.api.workspace.delete(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
|
||||
@@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient {
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
|
||||
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
|
||||
@@ -380,6 +380,31 @@ describe('createFixtureApi', () => {
|
||||
expect(noop.result.value.workspace.updatedAt).toBe(before)
|
||||
})
|
||||
|
||||
it('workspace.delete removes only the Workspace row and emits the removal frame', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
|
||||
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
|
||||
await consuming
|
||||
expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }])
|
||||
const list = await api.workspace.list(req({}))
|
||||
if (!list.result.ok) throw new Error('workspace list failed')
|
||||
expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false)
|
||||
const sessions = await api.sessions.list(req({}))
|
||||
if (!sessions.result.ok) throw new Error('session list failed')
|
||||
expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha')
|
||||
})
|
||||
|
||||
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -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: d7b7bbc4e4e05893689a8f2dcac82763b4c67ef8
|
||||
README.zh.md: d2054170cd6f31505793812fff46cc0f2356ad75
|
||||
README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e
|
||||
README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778
|
||||
|
||||
@@ -6,7 +6,9 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot {
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
type WorkspaceDelta =
|
||||
| { type: 'upsert'; workspace: WorkspaceView }
|
||||
| { type: 'remove'; workspaceId: WorkspaceId }
|
||||
|
||||
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
|
||||
export class WorkspaceManager {
|
||||
private items: Workspace[] = []
|
||||
@@ -28,7 +32,16 @@ export class WorkspaceManager {
|
||||
private phase: WorkspaceListPhase = 'pending'
|
||||
private error: RpcError | null = null
|
||||
private inflight: Promise<void> | null = null
|
||||
private refreshFrames: WorkspaceView[] | null = null
|
||||
private refreshFrames: WorkspaceDelta[] | null = null
|
||||
/**
|
||||
* Ids this process has seen removed, kept for the connection's lifetime so
|
||||
* a late changed frame or a stale baseline row cannot resurrect a deleted
|
||||
* row. Correctness rests on Host ids never being reused (the registry mints
|
||||
* a fresh `randomUUID` per record, including when the same directory is
|
||||
* registered again) — a path-derived id scheme would turn these entries
|
||||
* into permanent blindfolds and must clear them instead.
|
||||
*/
|
||||
private readonly removedIds = new Set<WorkspaceId>()
|
||||
private snapshotCache: WorkspaceListSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
@@ -51,7 +64,7 @@ export class WorkspaceManager {
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
const established = this.itemViews()
|
||||
const frames: WorkspaceView[] = []
|
||||
const frames: WorkspaceDelta[] = []
|
||||
this.refreshFrames = frames
|
||||
this.notifier.markDirty()
|
||||
this.inflight = (async () => {
|
||||
@@ -61,7 +74,8 @@ export class WorkspaceManager {
|
||||
let items = this.phase === 'pending'
|
||||
? result.value.items
|
||||
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
|
||||
for (const workspace of frames) items = upsertWorkspace(items, workspace)
|
||||
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
|
||||
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
|
||||
this.installViews(items)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
@@ -111,6 +125,18 @@ export class WorkspaceManager {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Workspace registration and remove its local projection from the
|
||||
* unary response without waiting for the Host frame.
|
||||
* @param workspaceId - target workspace.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async delete(workspaceId: WorkspaceId): Promise<RpcResult<{ deleted: true }>> {
|
||||
const { result } = await this.api.workspace.delete({ workspaceId })
|
||||
if (result.ok) this.remove(workspaceId, true)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order, then publish the
|
||||
* returned snapshot without waiting for the changed frame.
|
||||
@@ -139,6 +165,7 @@ export class WorkspaceManager {
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
|
||||
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
|
||||
}
|
||||
|
||||
/** Re-pull the baseline after each connection generation. */
|
||||
@@ -175,7 +202,8 @@ export class WorkspaceManager {
|
||||
|
||||
/** Upsert one Host view, optionally retaining the local object that materialized it. */
|
||||
private upsert(view: WorkspaceView, identity?: Workspace): void {
|
||||
this.refreshFrames?.push(view)
|
||||
if (this.removedIds.has(view.workspaceId)) return
|
||||
this.refreshFrames?.push({ type: 'upsert', workspace: view })
|
||||
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
|
||||
// Mutation responses and changed frames race (two carriers, no ordering):
|
||||
// reject a snapshot strictly older than the installed projection so a
|
||||
@@ -195,6 +223,24 @@ export class WorkspaceManager {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Remove one id idempotently and retain a tombstone against late echoes. */
|
||||
private remove(workspaceId: WorkspaceId, direct = false): void {
|
||||
this.refreshFrames?.push({ type: 'remove', workspaceId })
|
||||
this.removedIds.add(workspaceId)
|
||||
const items = this.items.filter(item =>
|
||||
item.getSnapshot().view?.workspaceId !== workspaceId)
|
||||
if (items.length === this.items.length) {
|
||||
// The Host frame may have removed the row first but left its batched
|
||||
// notification pending. A successful unary echo still flushes that
|
||||
// committed state before the user action resolves.
|
||||
if (direct) this.notifier.notifyNow()
|
||||
return
|
||||
}
|
||||
this.items = items
|
||||
if (direct) this.notifier.notifyNow()
|
||||
else this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private installViews(views: readonly WorkspaceView[]): void {
|
||||
const existing = new Map(
|
||||
this.items.flatMap((workspace) => {
|
||||
@@ -234,3 +280,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
|
||||
? [workspace, ...items]
|
||||
: items.map((item, position) => position === index ? workspace : item)
|
||||
}
|
||||
|
||||
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
|
||||
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
|
||||
return delta.type === 'upsert'
|
||||
? upsertWorkspace(items, delta.workspace)
|
||||
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
|
||||
}
|
||||
|
||||
@@ -174,6 +174,16 @@ export class WorkspacesService {
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one Workspace registration. Sessions, session logs, and the
|
||||
* directory remain Host-owned outside this operation.
|
||||
* @param workspaceId - target workspace.
|
||||
*/
|
||||
async delete(workspaceId: WorkspaceId): Promise<void> {
|
||||
const result = await this.manager.delete(workspaceId)
|
||||
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
|
||||
* @param workspaceId - owning workspace.
|
||||
|
||||
@@ -96,6 +96,9 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
|
||||
() => Promise.resolve(ok({ deleted: true }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
@@ -103,6 +106,7 @@ export class FakeApiClient implements IApiClient {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
}
|
||||
|
||||
@@ -76,6 +76,48 @@ describe('WorkspaceManager', () => {
|
||||
ok: false, error: { code: 'internal', message: 'create transport' },
|
||||
})
|
||||
})
|
||||
|
||||
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
const manager = new WorkspaceManager(api)
|
||||
const hydration = manager.refresh()
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'removed' as never,
|
||||
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
|
||||
})
|
||||
gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
|
||||
await hydration
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'late-change' as never,
|
||||
payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'duplicate-remove' as never,
|
||||
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
|
||||
})
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('removes from the unary delete echo while a refresh is in flight', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
|
||||
const manager = new WorkspaceManager(api)
|
||||
await manager.refresh()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
const refresh = manager.refresh()
|
||||
|
||||
await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
|
||||
expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
|
||||
expect(manager.getSnapshot().items).toEqual([])
|
||||
gate.resolve(ok({ items: [workspace('gone')] as never[] }))
|
||||
await refresh
|
||||
expect(manager.getSnapshot().items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacesService', () => {
|
||||
@@ -175,4 +217,20 @@ describe('WorkspacesService', () => {
|
||||
}))
|
||||
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
await workspaces.refresh()
|
||||
await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
|
||||
expect(workspaces.list.getSnapshot().items).toEqual([])
|
||||
|
||||
api.onWorkspaceDelete = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
|
||||
}))
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-conversation/README.md
|
||||
README.md: b242812411d513931ecd2767622f9e23fb0aaa34
|
||||
README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12
|
||||
README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6
|
||||
README.zh.md: 88992176165ab11050a30c7df381479796908ba2
|
||||
|
||||
@@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
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: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the in-progress item. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent 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.
|
||||
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: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to 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 persistent 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.
|
||||
|
||||
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
|
||||
|
||||
@@ -33,3 +33,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||
- **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.
|
||||
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
|
||||
|
||||
@@ -33,3 +33,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -181,7 +181,8 @@ export function apply(ctx: Context): void {
|
||||
// 'conversation.chat.toolview' declaration) is on the ledger.
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
|
||||
// The bash sample rides that exact seam, in third-party posture.
|
||||
// The bash sample rides that exact seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The todo_write row rides the same seam (a product registration, not a sample).
|
||||
|
||||
@@ -37,17 +37,11 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Selection linkage: the selected call row wears the blue outline.
|
||||
button-info-fill flips 500→400 with the theme, hitting the darker-blue
|
||||
dark-mode spec exactly (business-primary stays 500 on both). */
|
||||
.callRow[data-selected] {
|
||||
outline: 1.5px solid var(--dsw-alias-button-info-fill);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* Selection still sets data-selected for details linkage; no outline —
|
||||
tool rows match Think chrome (no selected ring). */
|
||||
|
||||
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
|
||||
the code turn reads as one unit; each nested row is itself a .callRow
|
||||
(same components, same selection outline as top-level rows). */
|
||||
the code turn reads as one unit; each nested row is itself a .callRow. */
|
||||
.subCalls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* User bubble: right-aligned, figma r22 fill = the bubble specific token
|
||||
(#EDF3FE light / dark pair rides the token sheet). */
|
||||
/* User bubble: right-aligned column (bubble + IconActions). Figma
|
||||
User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */
|
||||
|
||||
/* Block spacing is the flow column's gap alone — no extra padding here. */
|
||||
.userRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
@@ -19,6 +20,46 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
|
||||
hover:none keeps actions visible (opacity:0 still hit-tests). */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.userRow:hover .actions,
|
||||
.userRow:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned),
|
||||
// steering (badged bubble), context injection and unknown-surface JSON rows.
|
||||
// Props are frozen node slices off the snapshot cache; memo holds across
|
||||
// streaming because unchanged nodes keep their references.
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
||||
// copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// injection and unknown-surface JSON rows. Props are frozen node slices off
|
||||
// the snapshot cache; memo holds across streaming because unchanged nodes
|
||||
// keep their references.
|
||||
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
|
||||
JsonBlock, MessageText, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
@@ -26,6 +30,35 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
return { text: texts.join(''), rest }
|
||||
}
|
||||
|
||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
// Denied permissions / iframe policy.
|
||||
}
|
||||
return
|
||||
}
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
exec('copy')
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
el.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -58,15 +91,52 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
|
||||
function UserActions({ text }: { text: string }) {
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
return (
|
||||
<div className={css.actions}>
|
||||
<Tooltip label="复制" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="编辑" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="编辑">
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'user': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
</div>
|
||||
<UserActions text={text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'steering': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
{node.kind === 'steering' && <span className={css.badge}>插话</span>}
|
||||
<span className={css.badge}>插话</span>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The dock entry as a plain registrant plugin (bash-sample posture).
|
||||
* The dock entry as a plain registrant plugin (bash posture).
|
||||
* `inject: ['conversation']` is the ordering seam: the conversation service
|
||||
* mounts after ui-conversation's slot registrations, so the
|
||||
* 'conversation.input.dock' declaration is on the ledger by then.
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||
the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||
padding: 8px 32px 12px;
|
||||
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
|
||||
margin + 6px here); error/status strips still carry their own margin. */
|
||||
padding: 6px 32px 12px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -323,7 +324,7 @@
|
||||
.stopping,
|
||||
.stopping:hover {
|
||||
background: var(--dsw-alias-button-primary-dimmed);
|
||||
color: var(--dsw-alias-brand-text);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.retry {
|
||||
|
||||
@@ -1,55 +1,53 @@
|
||||
/* Plan strip pinned above the composer: bordered card on the composer card's
|
||||
axis (776px column inside 32px side padding). Colors resolve through
|
||||
--dsw-alias-* tokens only; the active row rides the business blue, done
|
||||
rows fade to tertiary. */
|
||||
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
|
||||
tip surface, 14px radius, status icons + secondary item labels. Column is
|
||||
calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
|
||||
|
||||
.root {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 8px auto 0;
|
||||
width: calc(100% - 64px);
|
||||
margin: 0 auto;
|
||||
width: calc(100% - 88px);
|
||||
max-width: 776px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-weight: 510;
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.progress {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.activeHint {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -58,13 +56,15 @@
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0 12px 8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
@@ -72,40 +72,45 @@
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.item[data-status='completed'] .content {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.item[data-status='completed'] .glyph {
|
||||
.glyphCompleted {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.item[data-status='in_progress'] .content {
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.item[data-status='in_progress'] .glyph {
|
||||
.glyphProgress {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
animation: todo-progress-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.glyphPending {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes todo-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Figma strip is single-line; long items ellipsize with no inline expand. */
|
||||
.content {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// no data of its own, hidden while the list is empty. Mounted through the
|
||||
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
|
||||
// the selecting, so the panel takes the plain list and stays framework-free.
|
||||
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -16,45 +17,97 @@ export interface TodoPanelProps {
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */
|
||||
const STATUS_GLYPHS: Record<TodoItem['status'], string> = {
|
||||
completed: '✓', in_progress: '●', pending: '○',
|
||||
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
|
||||
/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unreachable todo status: ${String(value)}`)
|
||||
}
|
||||
|
||||
/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */
|
||||
function CompletedGlyph() {
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphCompleted}>
|
||||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path
|
||||
d="M10.9631 5.71411L7.70154 8.97571C7.48011 9.19714 7.27736 9.40099 7.09229 9.54993C6.89742 9.70669 6.66314 9.85279 6.3634 9.90027C6.2049 9.92534 6.04339 9.92534 5.88489 9.90027C5.58515 9.85279 5.35087 9.70669 5.15601 9.54993C4.97093 9.40099 4.76818 9.19714 4.54675 8.97571L3.03516 7.46411L3.96313 6.53613L5.47473 8.04773C5.7169 8.28989 5.86196 8.43389 5.97888 8.52795C6.08597 8.61409 6.10875 8.60701 6.08997 8.604C6.11259 8.60758 6.13571 8.60758 6.15833 8.604C6.13954 8.60701 6.16232 8.61409 6.26941 8.52795C6.38633 8.43389 6.53139 8.28989 6.77356 8.04773L10.0352 4.78613L10.9631 5.71411Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** In-progress: business-blue ring fading out; CSS spins the svg. */
|
||||
function ProgressGlyph() {
|
||||
const gradientId = useId()
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphProgress}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="2.5" y1="12" x2="10.5" y2="3.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="currentColor" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="7" cy="7" r="6.4" stroke={`url(#${gradientId})`} strokeWidth="1.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */
|
||||
function PendingGlyph() {
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphPending}>
|
||||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2.4 2.4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusGlyph({ status }: { status: TodoItem['status'] }) {
|
||||
switch (status) {
|
||||
case 'completed': return <CompletedGlyph />
|
||||
case 'in_progress': return <ProgressGlyph />
|
||||
case 'pending': return <PendingGlyph />
|
||||
/* v8 ignore next -- closed TodoItem status union */
|
||||
default: return assertNever(status)
|
||||
}
|
||||
}
|
||||
|
||||
/** Header summary: "<done>/<total> tasks · <n> in progress". */
|
||||
function progressLabel(todos: readonly TodoItem[]): string {
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.filter(t => t.status === 'in_progress').length
|
||||
return `${done}/${todos.length} tasks · ${active} in progress`
|
||||
}
|
||||
|
||||
export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
if (todos.length === 0) return null
|
||||
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.find(t => t.status === 'in_progress')
|
||||
|
||||
return (
|
||||
<section className={css.root} data-testid="todo-panel" aria-label="任务清单">
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => { setCollapsed(v => !v) }}
|
||||
>
|
||||
<span className={css.title}>Plan</span>
|
||||
<span className={css.progress}>{done}/{todos.length}</span>
|
||||
{collapsed && active !== undefined && (
|
||||
<span className={css.activeHint}>{active.content}</span>
|
||||
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
|
||||
<div className={css.body}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => { setCollapsed(v => !v) }}
|
||||
>
|
||||
<span className={css.title}>To-dos</span>
|
||||
<span className={css.progress}>{progressLabel(todos)}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className={css.list}>
|
||||
{todos.map(item => (
|
||||
<li key={item.content} className={css.item} data-status={item.status}>
|
||||
<span className={css.glyph} aria-hidden><StatusGlyph status={item.status} /></span>
|
||||
<span className={css.content}>{item.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className={css.list}>
|
||||
{todos.map(item => (
|
||||
<li key={item.content} className={css.item} data-status={item.status}>
|
||||
<span className={css.glyph} aria-hidden>{STATUS_GLYPHS[item.status]}</span>
|
||||
<span className={css.content}>{item.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
/* Sample bash rows: deliberately distinct from ToolRow so the differential
|
||||
registry hit is visible at a glance. */
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
|
||||
.row {
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
.root:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
.leading {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.scopeBadge {
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
@@ -32,17 +35,38 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.command {
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.err {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,54 @@
|
||||
// Bash toolview sample, written in third-party posture: everything below uses
|
||||
// only the public slot surface (ctx.slots.register into the keyed
|
||||
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
|
||||
// that a plain plugin can take over a tool row with zero dedicated machinery.
|
||||
// Session-dimension differentiation happens INSIDE the component (the
|
||||
// canonical sub-agent scenario): rows in child sessions render the scoped
|
||||
// variant, derived from the standard useSessions kit — no registry predicates.
|
||||
// Bash toolview registrant: third-party posture over the keyed toolview hole
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
/** Bash row: command-first monospace summary replacing the generic card.
|
||||
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
|
||||
* the differential stays observable per session from one registration. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconApiOutline14 size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
if (isChild) {
|
||||
return (
|
||||
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
|
||||
<span className={css.scopeBadge}>scoped</span>
|
||||
<span className={css.command}>{model.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const status = stateStatus(model.state)
|
||||
return (
|
||||
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
|
||||
<span className={css.prompt} aria-hidden>$</span>
|
||||
<span className={css.command}>{model.summary}</span>
|
||||
{model.state === 'error' && <span className={css.err}>failed</span>}
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-clickable
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
|
||||
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
|
||||
// machinery specs since the tool ring dissolved into renderSlot.)
|
||||
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
// renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -18,7 +19,75 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
|
||||
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
})
|
||||
|
||||
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
const exec = vi.fn().mockReturnValue(true)
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: exec,
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
})
|
||||
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
@@ -29,6 +98,7 @@ describe('MessageItem arms', () => {
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context and unknown nodes render their JSON rows', () => {
|
||||
|
||||
@@ -156,12 +156,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(view.getByText('List the notes directory')).toBeTruthy()
|
||||
|
||||
// Nested rows are ALWAYS visible (no parent expand needed): the bash
|
||||
// sub-call landed in the bash sample plugin's keyed registration — the
|
||||
// exact component a native top-level bash row uses — and the unregistered
|
||||
// sub-call landed in the bash sample plugin's keyed registration — Bash ·
|
||||
// description chrome, same as a top-level bash row — and the unregistered
|
||||
// sub-tool fell back to GenericToolCard at the same render site.
|
||||
const nest = view.container.querySelector('[data-subcalls]')
|
||||
expect(nest).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('List notes')).toBeTruthy()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -169,17 +169,19 @@ describe('bash sample row', () => {
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes the command and hands clicks to openDetails on both arms', () => {
|
||||
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
|
||||
const openGlobal = vi.fn()
|
||||
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Bash')
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
fireEvent.click(globalRow)
|
||||
expect(openGlobal).toHaveBeenCalledTimes(1)
|
||||
const openScoped = vi.fn()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Bash')
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
fireEvent.click(scopedRow)
|
||||
expect(openScoped).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('ChatView', () => {
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
|
||||
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// PendingCard question arm, bash sample error pill, the node-half empty
|
||||
// PendingCard question arm, bash sample state dots, the node-half empty
|
||||
// apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -76,14 +76,7 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow shows the failed pill on error results (root session arm)', () => {
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
// Root session (no parentId): the global arm renders, error pill visible.
|
||||
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
@@ -91,12 +84,40 @@ describe('tails', () => {
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
const props = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps
|
||||
const view = render(<BashRow {...props} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('failed')).toBeTruthy()
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
const running: RunningToolCall = {
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
|
||||
turn: 1, step: 1, time: 1_000, callView: null,
|
||||
}
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
const stoppedResult: ToolResultNode = {
|
||||
...errorResult,
|
||||
error: { name: 'E', code: 'interrupted' },
|
||||
}
|
||||
|
||||
const runningView = render(<BashRow {...props(running)} />)
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(runningView.getByText('Bash')).toBeTruthy()
|
||||
expect(runningView.getByText('List')).toBeTruthy()
|
||||
runningView.unmount()
|
||||
|
||||
const errorView = render(<BashRow {...props(errorResult)} />)
|
||||
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(errorView.getByText('失败')).toBeTruthy()
|
||||
errorView.unmount()
|
||||
|
||||
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
|
||||
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(stoppedView.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse with active hint), its TodoDock adapter (selects the plan off
|
||||
* the session snapshot and follows changes), and the todo_write toolview row
|
||||
* (progress summary from args, generic fallback on malformed JSON, error badge,
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, error badge,
|
||||
* keyboard activation).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
@@ -31,32 +31,36 @@ describe('TodoPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows progress, one row per item with its status, and strikes done items', () => {
|
||||
it('shows progress, one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('1/3')).toBeTruthy()
|
||||
expect(screen.getByText('To-dos')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
const items = screen.getAllByRole('listitem')
|
||||
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
|
||||
expect(screen.getByText('搭骨架')).toBeTruthy()
|
||||
expect(screen.getByText('写组件')).toBeTruthy()
|
||||
// Each status row carries an SVG glyph (not a text bullet).
|
||||
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('collapse hides the list and surfaces the active item in the header; expand restores', () => {
|
||||
it('collapse hides the list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
const header = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header carries the in-progress content as the one-line hint.
|
||||
expect(screen.getByText('写组件')).toBeTruthy()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('collapsed header omits the hint when nothing is in progress', () => {
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1')).toBeTruthy()
|
||||
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,7 +75,7 @@ describe('TodoDock', () => {
|
||||
render(<TodoDock {...dockProps(store)} />)
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ todos: LIST }) })
|
||||
expect(screen.getByText('1/3')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
// A rollback to the empty list retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ todos: [] }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
|
||||
@@ -1,6 +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
|
||||
README.md: 6aeda0e04bf690f5cc8a6d52eea95b1b2782a143
|
||||
README.zh.md: 9fbc2ed8392ae3ceba453517d9af5ef41f039d2d
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
|
||||
README.md: 26e909b96412985792eeae72d51a2ab2a315c943
|
||||
README.zh.md: 2e5799fd32c41328f8ca8b9e1a439fbccb3cdba2
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables).
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
|
||||
|
||||
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 `document.body`(依据当前配色方案设置 `data-ds-dark-theme`,并将主题的别名 token 设为内联变量)。
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
|
||||
|
||||
AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。
|
||||
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
/**
|
||||
* Global theme DOM applier: projects the resolved ThemeSnapshot onto
|
||||
* document.body — the `data-ds-dark-theme` palette switch plus the active
|
||||
* theme's alias-token overrides as inline CSS variables. Pure DOM writes, no
|
||||
* React involvement; the presenter only ever retracts what it wrote itself,
|
||||
* so foreign body attributes and inline styles survive apply/dispose.
|
||||
* Global theme DOM applier: projects the resolved ThemeSnapshot onto the
|
||||
* document — `html { color-scheme }` for native UA chrome (scrollbars, form
|
||||
* controls), `body[data-ds-dark-theme]` for the token palette, and the active
|
||||
* theme's alias-token overrides as inline CSS variables on body. Pure DOM
|
||||
* writes, no React involvement; the presenter only ever retracts what it wrote
|
||||
* itself, so foreign attributes and inline styles survive apply/dispose.
|
||||
*/
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
/** Body attribute selecting the dark base palette in the token stylesheets. */
|
||||
export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
|
||||
/** Applies theme snapshots to document.body; one instance per plugin fiber. */
|
||||
/** Applies theme snapshots to the document; one instance per plugin fiber. */
|
||||
export class ThemePresenter {
|
||||
/** Token names this presenter wrote in the last apply (its retraction set). */
|
||||
private appliedTokens: string[] = []
|
||||
|
||||
/**
|
||||
* Project a snapshot onto the body: switch the palette attribute from
|
||||
* `active.colorScheme` (never the id — `system` is resolved upstream) and
|
||||
* replace the previously applied token variables with `active.tokens`.
|
||||
* Project a snapshot onto the document: set root `color-scheme` and the body
|
||||
* palette attribute from `active.colorScheme` (never the id — `system` is
|
||||
* resolved upstream), then replace the previously applied token variables
|
||||
* with `active.tokens`.
|
||||
* @param snapshot - resolved theme snapshot from ctx.theme.
|
||||
*/
|
||||
apply(snapshot: ThemeSnapshot): void {
|
||||
const scheme = snapshot.active.colorScheme
|
||||
document.documentElement.style.colorScheme = scheme
|
||||
const body = document.body
|
||||
if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
|
||||
if (scheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
|
||||
else body.removeAttribute(DARK_ATTRIBUTE)
|
||||
for (const name of this.appliedTokens) body.style.removeProperty(name)
|
||||
this.appliedTokens = []
|
||||
@@ -33,8 +37,9 @@ export class ThemePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Retract everything this presenter wrote: the palette attribute and all applied token variables. */
|
||||
/** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */
|
||||
dispose(): void {
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
const body = document.body
|
||||
body.removeAttribute(DARK_ATTRIBUTE)
|
||||
for (const name of this.appliedTokens) body.style.removeProperty(name)
|
||||
|
||||
@@ -63,15 +63,19 @@ describe('ui-layout client apply', () => {
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
// Initial getter application: jsdom has no matchMedia, system resolves light.
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
const theme = ctx.get('theme') as ThemeService
|
||||
theme.setTheme('dark')
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
// Listener is off: further theme changes no longer reach the body.
|
||||
// Listener is off: further theme changes no longer reach the document.
|
||||
theme.setTheme('light')
|
||||
theme.setTheme('dark')
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// ThemePresenter behavior account: the palette attribute follows
|
||||
// active.colorScheme only, token variables replace the previous apply's set,
|
||||
// and dispose retracts everything the presenter wrote.
|
||||
// ThemePresenter behavior account: root color-scheme and the palette attribute
|
||||
// follow active.colorScheme only, token variables replace the previous apply's
|
||||
// set, and dispose retracts everything the presenter wrote.
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
@@ -14,22 +14,26 @@ function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
document.body.removeAttribute(DARK_ATTRIBUTE)
|
||||
document.body.removeAttribute('style')
|
||||
})
|
||||
|
||||
describe('ThemePresenter', () => {
|
||||
it('light scheme leaves the dark attribute absent', () => {
|
||||
it('light scheme sets root color-scheme and leaves the dark attribute absent', () => {
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('light'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it('dark scheme sets the attribute; switching back to light removes it', () => {
|
||||
it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => {
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('dark'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
|
||||
presenter.apply(snapshot('light'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -44,11 +48,12 @@ describe('ThemePresenter', () => {
|
||||
expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('')
|
||||
})
|
||||
|
||||
it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => {
|
||||
it('dispose removes color-scheme, the attribute, and every applied variable, sparing foreign inline styles', () => {
|
||||
document.body.style.setProperty('--foreign', 'kept')
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
|
||||
presenter.dispose()
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
|
||||
expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')
|
||||
|
||||
@@ -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
|
||||
README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d
|
||||
README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7
|
||||
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
|
||||
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,13 +1,79 @@
|
||||
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
|
||||
and the fallback <pre> draw identically except for token colors. */
|
||||
/* Visual baseline: deepsuite `@deepseek/md` code-block.css. Highlight colors
|
||||
stay on the existing shiki `--shiki-*` sheet (not Prism highlight.css). */
|
||||
|
||||
.block {
|
||||
--dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
|
||||
--dsl-code-block-border-radius: 12px;
|
||||
--dsl-code-block-banner-font: var(--dsw-font-xs-13);
|
||||
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
|
||||
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-code-block-border-radius);
|
||||
}
|
||||
|
||||
.block:not(:last-child) {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.bannerWrap {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
background-color: var(--dsw-alias-bg-base);
|
||||
border-top-left-radius: var(--dsl-code-block-border-radius);
|
||||
border-top-right-radius: var(--dsl-code-block-border-radius);
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: var(--dsl-code-block-banner-background-color);
|
||||
padding: 9px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font: var(--dsl-code-block-banner-font);
|
||||
border-top-left-radius: var(--dsl-code-block-border-radius);
|
||||
border-top-right-radius: var(--dsl-code-block-border-radius);
|
||||
}
|
||||
|
||||
.infostring {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background-color: rgb(255 255 255 / 0);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.block :where(pre) {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font: var(--dsl-code-block-content-font);
|
||||
padding: 16px;
|
||||
margin: 0 !important;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
}
|
||||
|
||||
/* Shiki inlines its theme background var; route it to the repo token. */
|
||||
@@ -23,5 +89,4 @@
|
||||
|
||||
.plain {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// CodeBlock: one code surface for every consumer — markdown fences, the
|
||||
// run_code program body, and the details panel's raw args/output — with
|
||||
// shiki highlighting for the registered grammars and an identical-geometry
|
||||
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
|
||||
// tree of nested spans whose colors are --shiki-* custom properties
|
||||
// (token sheets own the values); it produces no scripts or event handlers,
|
||||
// so injecting its output is safe by construction.
|
||||
// plain fallback for everything else. Chrome (language banner + copy) matches
|
||||
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { highlightToHtml } from './highlight.ts'
|
||||
import css from './CodeBlock.module.css'
|
||||
@@ -20,18 +18,80 @@ export interface CodeBlockProps {
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** @returns true only when the host accepted the write. */
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Denied permissions / iframe policy — do not claim success.
|
||||
return false
|
||||
}
|
||||
}
|
||||
// jsdom and older hosts: best-effort execCommand path when present.
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return false
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
return exec('copy')
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
el.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
if (html === undefined) {
|
||||
return (
|
||||
<div className={clsx(css.block, className)}>
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
|
||||
typed fallback if the DOM shape ever diverges. */
|
||||
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1000)
|
||||
})
|
||||
}, [copied, trimmed])
|
||||
|
||||
const body = html === undefined
|
||||
? (
|
||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||
)
|
||||
: (
|
||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
||||
// span tree it generated from `code` (no user HTML passes through), the
|
||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||
<div className={css.bannerWrap}>
|
||||
<div className={css.banner}>
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
||||
// span tree it generated from `code` (no user HTML passes through), the
|
||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
||||
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,95 +1,168 @@
|
||||
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
|
||||
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
|
||||
intentionally absent (no matching DOM). Token names match that sheet. */
|
||||
|
||||
.markdown {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-base);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
|
||||
margin: 0;
|
||||
.markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font: var(--dsw-font-markdown-h1);
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font: var(--dsw-font-markdown-h2);
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font: var(--dsw-font-markdown-h3);
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
.markdown :where(h4, h5, h6) {
|
||||
.markdown h4 {
|
||||
font: var(--dsw-font-markdown-h4);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown :where(strong, th) {
|
||||
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
|
||||
.markdown :where(h5, h6) {
|
||||
font: var(--dsw-font-markdown-base-strong);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) {
|
||||
padding-inline-start: 24px;
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6) strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.markdown li + li {
|
||||
margin-block-start: 4px;
|
||||
.markdown p {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown li > :where(ul, ol) {
|
||||
margin-block-start: 4px;
|
||||
/* Tighten h4–h6 against a following list (design: 8px gap). */
|
||||
.markdown :where(h4, h5, h6) + :where(ul, ol) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
/* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet
|
||||
keeps design-platform brand-text as near-black, so links use the blue
|
||||
business-primary alias instead. */
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
/* Transparent hit-area padding; literal zero-alpha only (no painted color). */
|
||||
border-left: 3px solid rgb(255 255 255 / 0);
|
||||
border-right: 3px solid rgb(255 255 255 / 0);
|
||||
border-top: 2px solid rgb(255 255 255 / 0);
|
||||
border-bottom: 2px solid rgb(255 255 255 / 0);
|
||||
margin-left: -3px;
|
||||
margin-right: -3px;
|
||||
}
|
||||
|
||||
.markdown :not(pre) > code {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-markdown-inline-code);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
.markdown a:hover,
|
||||
.markdown a:focus {
|
||||
outline: none;
|
||||
text-decoration: underline var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
.markdown a:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
white-space: pre;
|
||||
.markdown :where(ul, ol) {
|
||||
margin: 16px 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.markdown li:not(:first-child) {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.markdown li > :where(ul, ol) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.markdown li::marker {
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */
|
||||
.markdown :where(ul, ol) ol {
|
||||
list-style-position: inside;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) ol li p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown li > p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown li > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */
|
||||
.markdown li > *:last-child:not(:global(.md-code-block)) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
|
||||
display: block;
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 32px 0;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
border-left: 2px solid var(--dsw-alias-label-caption);
|
||||
margin: 16px 0 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
margin: 16px 0;
|
||||
font-family: var(--ds-font-family-code);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown :not(pre) > code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
font: var(--dsw-font-markdown-code);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 0.875em !important;
|
||||
background-color: var(--dsw-alias-markdown-inline-code);
|
||||
border-radius: 6px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6) code {
|
||||
font: inherit;
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-state-business-primary);
|
||||
accent-color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
@@ -99,22 +172,52 @@
|
||||
}
|
||||
|
||||
.tableScroll table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--dsw-font-markdown-table);
|
||||
}
|
||||
|
||||
.tableScroll :where(th, td) {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--dsw-alias-markdown-citation);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
width: max-content;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.tableScroll th {
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
text-align: start;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||
border-top: none;
|
||||
font: var(--dsw-font-markdown-table-head);
|
||||
max-width: 320px;
|
||||
max-width: min(30vw, 320px);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.tableScroll td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
font: var(--dsw-font-markdown-table);
|
||||
max-width: 320px;
|
||||
max-width: min(30vw, 320px);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.tableScroll th:first-child,
|
||||
.tableScroll td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tableScroll td:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.tableScroll table code {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown > *:first-child,
|
||||
.markdown p:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.markdown > *:last-child,
|
||||
.markdown p:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.imageAlt {
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
|
||||
// alongside the rest of the markdown family.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
|
||||
import { highlightToHtml } from '../src/markdown/highlight.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('highlightToHtml', () => {
|
||||
it('highlights a registered grammar into css-variables token spans', () => {
|
||||
const html = highlightToHtml('const x: number = 1', 'typescript')
|
||||
@@ -50,4 +53,86 @@ describe('CodeBlock', () => {
|
||||
expect(view.container.querySelector('pre.shiki')).toBeNull()
|
||||
expect(view.getByText('plain text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the language banner and copies the pre textContent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
|
||||
expect(screen.getByText('ts')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('const a = 1')
|
||||
// Flush the clipboard promise under fake timers before asserting the label.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
// While the ok label is showing, further clicks are no-ops.
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not claim success when clipboard.writeText rejects', async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(<CodeBlock code="plain body" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
const exec = vi.fn().mockReturnValue(true)
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: exec,
|
||||
})
|
||||
render(<CodeBlock code="plain body" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not claim success when execCommand throws or is absent', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
})
|
||||
const denied = render(<CodeBlock code="plain body" />)
|
||||
fireEvent.click(denied.getByRole('button', { name: '复制' }))
|
||||
await Promise.resolve()
|
||||
expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
denied.unmount()
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
const absent = render(<CodeBlock code="plain body" />)
|
||||
fireEvent.click(absent.getByRole('button', { name: '复制' }))
|
||||
await Promise.resolve()
|
||||
expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,8 +57,10 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
|
||||
expect(container.querySelector('hr')).not.toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
|
||||
// The ts fence routed through the shared CodeBlock: shiki token spans present.
|
||||
// The ts fence routed through the shared CodeBlock: shiki token spans + banner.
|
||||
expect(container.querySelector('pre.shiki')).not.toBeNull()
|
||||
expect(screen.getByText('ts')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(container.querySelector('br')).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
|
||||
|
||||
@@ -1,6 +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
|
||||
README.md: 5c9794d4f5faea42861f47423d4e8980cbf89216
|
||||
README.zh.md: 2e0f76133ee38ba2fdc385e7fbb2a4a03b733cfa
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||
README.md: 1227df357cb93241fcf28da9b74d7ba15207e9c5
|
||||
README.zh.md: cd87ede7264c8d47dd780acaa11128e83d7862f9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8.
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为内联变量)。契约:api-contracts v3 §8。
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
|
||||
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
|
||||
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ds-transition-duration: 0.2s;
|
||||
--ds-transition-duration-fast: 0.1s;
|
||||
--ds-transition-duration-slow: 0.3s;
|
||||
}
|
||||
|
||||
@@ -1,6 +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
|
||||
README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33
|
||||
README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259
|
||||
README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
|
||||
|
||||
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; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new 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 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; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new 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.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
@@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Workspace rename/delete controls** — the picker supports selection and creation only.
|
||||
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建。
|
||||
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。
|
||||
|
||||
@@ -258,6 +258,16 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteAction:not(:disabled) {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteStatus {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wide {
|
||||
animation: none;
|
||||
|
||||
@@ -87,10 +87,15 @@ type SessionTreeProps = Pick<
|
||||
query: string
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, workspaces, query,
|
||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
@@ -128,11 +133,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
onRename={group.workspaceId === undefined
|
||||
actions={group.workspaceId === undefined
|
||||
? undefined
|
||||
: () => {
|
||||
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
: {
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
@@ -236,6 +247,7 @@ export function WorkspaceBrowser({
|
||||
startSession,
|
||||
open,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
}: WorkspaceBrowserProps) {
|
||||
@@ -291,6 +303,41 @@ export function WorkspaceBrowser({
|
||||
})
|
||||
}
|
||||
|
||||
// Delete dialog is separate from the row so a successful removal can
|
||||
// unmount that row without tearing down the in-flight confirmation state.
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteCommittedId, setDeleteCommittedId] = useState<WorkspaceId | null>(null)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (deleteCommittedId === null
|
||||
|| workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return
|
||||
setDeleting(false)
|
||||
setDeleteCommittedId(null)
|
||||
setDeleteTarget(null)
|
||||
}, [deleteCommittedId, workspaces])
|
||||
const closeDelete = () => {
|
||||
if (deleting) return
|
||||
setDeleteTarget(null)
|
||||
setDeleteError(null)
|
||||
}
|
||||
const confirmDelete = () => {
|
||||
/* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */
|
||||
if (deleting || deleteTarget === null) return
|
||||
setDeleting(true)
|
||||
setDeleteCommittedId(null)
|
||||
setDeleteError(null)
|
||||
deleteWorkspace(deleteTarget.workspaceId).then(() => {
|
||||
// Keep the confirmation pending until this component has rendered the
|
||||
// committed list projection without the deleted id. Closing earlier
|
||||
// exposes one stale React frame to the next Create Workspace gesture.
|
||||
setDeleteCommittedId(deleteTarget.workspaceId)
|
||||
}).catch((reason: unknown) => {
|
||||
setDeleting(false)
|
||||
setDeleteError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, !wide && css.rail)}>
|
||||
<div className={css.sectionHeader}>
|
||||
@@ -382,6 +429,10 @@ export function WorkspaceBrowser({
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -416,6 +467,30 @@ export function WorkspaceBrowser({
|
||||
)}
|
||||
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={deleteTarget !== null}
|
||||
onClose={closeDelete}
|
||||
title="Delete workspace"
|
||||
{...deleteTarget === null
|
||||
? {}
|
||||
: { description: `This removes “${deleteTarget.title}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.` }}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteAction!}
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete workspace
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{deleting && <div className={css.deleteStatus} role="status">Deleting workspace…</div>}
|
||||
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const normalizedWorkspaceName = workspaceName.trim()
|
||||
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
|
||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
|
||||
@@ -32,6 +32,8 @@ export type WorkspaceBrowserInjected = {
|
||||
open: (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. */
|
||||
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Reorder a session inside its Workspace account (DOM-insertBefore
|
||||
* semantics: omitted anchor appends to the end). The view refreshes from
|
||||
|
||||
@@ -39,6 +39,7 @@ export function apply(ctx: ClientContext): void {
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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; the session hover card is suppressed while a menu
|
||||
* is open.
|
||||
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
|
||||
export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
|
||||
group: GroupNode
|
||||
onToggle: () => void
|
||||
onCreate: () => void
|
||||
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
|
||||
onRename?: (() => void) | undefined
|
||||
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
|
||||
actions?: { rename: () => void; delete: () => void } | undefined
|
||||
}) {
|
||||
const row = group
|
||||
const active = group.expanded && group.containsCurrent
|
||||
@@ -68,15 +68,19 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
|
||||
<span className={css.meta}>{count}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
{onRename !== undefined && (
|
||||
{actions !== undefined && (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={WORKSPACE_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename()
|
||||
// Delete is visual-only for now.
|
||||
// Unknown ids leave before the dispatch: a future menu row must
|
||||
// not inherit the destructive branch as an else fallback.
|
||||
/* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
|
||||
if (id !== 'rename' && id !== 'delete') return
|
||||
if (id === 'rename') actions.rename()
|
||||
else actions.delete()
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
|
||||
@@ -98,12 +98,16 @@ describe('workspace browser rows', () => {
|
||||
|
||||
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
|
||||
const onRename = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
|
||||
render(<ProjectRowItem
|
||||
group={group} onToggle={onToggle} onCreate={vi.fn()}
|
||||
actions={{ rename: onRename, delete: onDelete }}
|
||||
/>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
// Opening the menu neither toggles the group nor renames yet.
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
@@ -111,11 +115,11 @@ describe('workspace browser rows', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Delete stays visual-only: selecting it just closes the menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
@@ -54,6 +54,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
...overrides,
|
||||
@@ -457,6 +458,79 @@ describe('WorkspaceBrowser', () => {
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
})
|
||||
|
||||
it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
|
||||
let resolveDelete!: () => void
|
||||
const deleteWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveDelete = resolve }))
|
||||
const browser = mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Delete workspace' })
|
||||
expect(dialog.textContent).toContain('removes “Alpha” from the workspace list')
|
||||
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
||||
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
||||
|
||||
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
await act(async () => { resolveDelete() })
|
||||
// RPC success alone does not close: the component waits until its
|
||||
// useWorkspaces projection has committed the removal, preventing a stale
|
||||
// duplicate-name frame from leaking into the next create gesture.
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
|
||||
const deleteWorkspace = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockRejectedValueOnce('denied')
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
})
|
||||
|
||||
it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
|
||||
const deleteWorkspace = vi.fn(async () => {})
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
const open = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
}
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
open()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(deleteWorkspace).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
})
|
||||
|
||||
it('search hides drag affordances (rows are not draggable during search)', () => {
|
||||
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
|
||||
mount({
|
||||
|
||||
@@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } {
|
||||
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
const anchorRef = anchor()
|
||||
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
|
||||
<WorkspacePicker
|
||||
open
|
||||
anchorRef={anchor()}
|
||||
anchorRef={anchorRef}
|
||||
useSessions={hook(sessions)}
|
||||
useWorkspaces={hook(workspaceState(items))}
|
||||
useWorkspaces={hook(workspaceState(nextItems))}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
/>,
|
||||
/>
|
||||
)
|
||||
return { view, onPick, onClose, createWorkspace }
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
|
||||
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
|
||||
@@ -106,6 +113,22 @@ describe('WorkspacePicker', () => {
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => {
|
||||
let resolve!: (workspace: WorkspaceView) => void
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('fresh', 'same-name')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
b.rerenderItems([created])
|
||||
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
|
||||
})
|
||||
|
||||
it('exposes creation phase and error text while retaining the modal for retry', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
|
||||
@@ -950,6 +950,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'list(): Workspace[]',
|
||||
jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'delete(id: WorkspaceId): Promise<boolean>',
|
||||
jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
|
||||
jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
|
||||
|
||||
@@ -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: 775ceec5b2171b81977650b739b5b94662902dd5
|
||||
README.zh.md: d28d6cdad43b7c944f6bdf17e80e99494b39308f
|
||||
README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1
|
||||
README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020
|
||||
|
||||
@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
|
||||
|
||||
|
||||
@@ -777,6 +777,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return ok(request, { workspace: workspaceView(workspace) })
|
||||
},
|
||||
|
||||
async delete(request) {
|
||||
const { workspaceId } = request.payload
|
||||
const operation = workspaceCreationChain.then(() =>
|
||||
ctx.workspace.delete(brandWorkspaceId(workspaceId)))
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
if (!await operation) return workspaceNotFound(request, workspaceId)
|
||||
return ok(request, { deleted: true as const })
|
||||
},
|
||||
|
||||
async insertSessionBefore(request) {
|
||||
const { payload } = request
|
||||
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
|
||||
@@ -990,8 +999,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
|
||||
}),
|
||||
ctx.on('domain/changed', (change) => {
|
||||
if (change.domain !== 'workspace' || change.operation !== 'put') return
|
||||
if (change.domain !== 'workspace') return
|
||||
if (change.table === '') {
|
||||
if (change.operation !== 'put') return
|
||||
const state = workspaceDomainState.parse(change.value)
|
||||
for (const workspaceId of state.workspaceIds) {
|
||||
if (committedWorkspaceIds.has(workspaceId)) continue
|
||||
@@ -1004,7 +1014,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
return
|
||||
}
|
||||
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
|
||||
if (change.table !== 'workspaces') return
|
||||
if (change.operation === 'deleted') {
|
||||
if (!committedWorkspaceIds.delete(change.key)) return
|
||||
queue.push(frame({
|
||||
type: 'host/workspace-removed',
|
||||
workspaceId: change.key as WorkspaceId,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (!committedWorkspaceIds.has(change.key)) return
|
||||
// Existing-entity table writes are complete attach/touch commits.
|
||||
// A new entity's first put waits for the global registry write above.
|
||||
queue.push(frame({
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
|
||||
import { workspaceViewSchema } from './workspace.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
export const askUserQuestionItemSchema = z.object({
|
||||
@@ -47,6 +47,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -85,7 +85,9 @@ export type MuxFrame =
|
||||
* agent-error is the only outlet for live failures with no turn position;
|
||||
* workspace-changed pushes the full new snapshot after every durable
|
||||
* workspace mutation (create/attach/order change — the client upserts, while
|
||||
* `workspace.list` provides the reconnect baseline).
|
||||
* `workspace.list` provides the reconnect baseline); workspace-removed is the
|
||||
* committed registration-deletion increment and never implies directory or
|
||||
* session-log deletion.
|
||||
*/
|
||||
export type HostFrame =
|
||||
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
|
||||
@@ -93,6 +95,7 @@ export type HostFrame =
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
|
||||
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
|
||||
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
|
||||
/**
|
||||
* The command registry changed (`commands/change` passthrough). Pure
|
||||
* invalidation signal, no payload: clients refetch `command.list` in the
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface RpcMethodMap {
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.delete': WorkspaceApi['delete']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
|
||||
@@ -59,6 +59,16 @@ export const workspaceRenameValueSchema = z.object({
|
||||
workspace: workspaceViewSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
|
||||
|
||||
/** workspace.delete request payload. */
|
||||
export const workspaceDeleteRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.delete'>>>
|
||||
|
||||
/** workspace.delete response value. */
|
||||
export const workspaceDeleteValueSchema = z.object({
|
||||
deleted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
|
||||
|
||||
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
|
||||
export const workspaceInsertSessionBeforeRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
|
||||
@@ -65,6 +65,14 @@ export interface WorkspaceApi {
|
||||
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView }>>
|
||||
|
||||
/**
|
||||
* Removes one Workspace registration. The directory, every user file, and
|
||||
* every session log remain untouched; those Sessions consequently become
|
||||
* ungrouped. An unknown id fails with `workspace-not-found`.
|
||||
*/
|
||||
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
|
||||
Promise<RpcResponse<{ deleted: true }>>
|
||||
|
||||
/**
|
||||
* Moves an accounted session within its workspace's manual order,
|
||||
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
workspaceCreateValueSchema,
|
||||
workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
@@ -60,6 +61,7 @@ export interface IApiClient {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
|
||||
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
|
||||
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
|
||||
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
|
||||
}
|
||||
commands: {
|
||||
@@ -91,6 +93,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.delete': workspaceDeleteValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
@@ -285,6 +288,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
|
||||
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
|
||||
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
|
||||
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceDeleteRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
@@ -57,6 +58,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
|
||||
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
|
||||
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
|
||||
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
|
||||
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
|
||||
@@ -244,4 +244,37 @@ describe('Host Workspace increments', () => {
|
||||
abort.abort()
|
||||
expect(await next).toMatchObject({ done: true })
|
||||
})
|
||||
|
||||
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
|
||||
const sessionId = SessionId('session-kept-after-workspace-delete')
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const removed = nextHostFrame(stream)
|
||||
expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
|
||||
expect(await removed).toMatchObject({
|
||||
payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
|
||||
})
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
||||
expect(ctx.agents.get(sessionId)).toBeDefined()
|
||||
expect(existsSync(workspace.path)).toBe(true)
|
||||
|
||||
const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
|
||||
expect(missing.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
|
||||
})
|
||||
|
||||
const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
|
||||
expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
|
||||
expect(reregistered.path).toBe(workspace.path)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ function scriptedApi(overrides: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
delete: r => ok(r, { deleted: true as const }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
},
|
||||
commands: {
|
||||
@@ -76,13 +77,15 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
|
||||
})
|
||||
|
||||
it('routes workspace rename and insertSessionBefore through the wire', async () => {
|
||||
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
|
||||
const api = scriptedApi()
|
||||
const c = client(api)
|
||||
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
|
||||
expect(renamed.result.ok).toBe(true)
|
||||
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
|
||||
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
const deleted = await c.workspace.delete({ workspaceId: 'w1' as never })
|
||||
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
|
||||
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
|
||||
expect(anchored.result.ok).toBe(true)
|
||||
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
|
||||
|
||||
@@ -64,6 +64,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
|
||||
}
|
||||
},
|
||||
async delete(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
@@ -177,6 +178,13 @@ describe('workspace domain schemas', () => {
|
||||
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
||||
})
|
||||
|
||||
it('validates workspace deletion payload and receipt', () => {
|
||||
expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1')
|
||||
expect(() => workspaceDeleteRequestSchema.parse({})).toThrow()
|
||||
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
|
||||
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
@@ -273,6 +281,11 @@ describe('events frame schemas', () => {
|
||||
{ type: 'host/session-removed', sessionId: 's' },
|
||||
{ type: 'host/session-status', sessionId: 's', running: true },
|
||||
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
|
||||
{ type: 'host/workspace-changed', workspace: {
|
||||
workspaceId: 'w', path: '/w', title: 'w', sessionIds: [],
|
||||
createdAt: '0', updatedAt: '0',
|
||||
} },
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
|
||||
@@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(mode: 'danger-full-access' | 'workspace-write') {
|
||||
async function harness(
|
||||
mode: 'danger-full-access' | 'workspace-write',
|
||||
timing: { idleSilenceMs?: number; timeoutMs?: number } = {},
|
||||
) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
|
||||
roots.push(root)
|
||||
const ctx = new Context()
|
||||
@@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
|
||||
const fiber = await ctx.plugin(ptyLocal, {
|
||||
pollIntervalMs: 10,
|
||||
exactProbeAfterMs: 20,
|
||||
idleSilenceMs: 250,
|
||||
timeoutMs: 2000,
|
||||
idleSilenceMs: timing.idleSilenceMs ?? 250,
|
||||
timeoutMs: timing.timeoutMs ?? 2_000,
|
||||
disposeGraceMs: 500,
|
||||
scrollbackLines: 100,
|
||||
scrollbackMaxBytes: 32_768,
|
||||
@@ -63,8 +66,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
|
||||
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
|
||||
}
|
||||
|
||||
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
|
||||
const deadline = Date.now() + 2_000
|
||||
async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let output = ''
|
||||
while (!output.includes(expected) && Date.now() < deadline) {
|
||||
output += operation.readOutput().delta
|
||||
@@ -131,20 +134,25 @@ describe('pty-local real shell', () => {
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
}, 10_000)
|
||||
|
||||
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
|
||||
const { ctx, agent } = await harness('danger-full-access')
|
||||
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
|
||||
const { ctx, agent } = await harness('danger-full-access', {
|
||||
idleSilenceMs: 10_000,
|
||||
timeoutMs: 15_000,
|
||||
})
|
||||
const created = await ctx.pty.spawn(agent, { type: 'shell' })
|
||||
const controller = new AbortController()
|
||||
const ready = 'RAW_READY'
|
||||
// Delay readiness beyond the shared harness's short send bound so this
|
||||
// process test owns enough slack for loaded macOS startup and shell echo.
|
||||
// The interactive shell echoes the command, so only child output may contain the readiness marker.
|
||||
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\''
|
||||
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\''
|
||||
expect(command).not.toContain(ready)
|
||||
const foreground = ctx.pty.startSend(agent, created.sessionId, {
|
||||
text: command,
|
||||
submit: true,
|
||||
signal: controller.signal,
|
||||
})
|
||||
await waitForOutput(foreground, ready)
|
||||
await waitForOutput(foreground, ready, 15_000)
|
||||
controller.abort()
|
||||
const result = await foreground.done
|
||||
expect(result.waitReason).toBe('stdin_read')
|
||||
@@ -155,5 +163,5 @@ describe('pty-local real shell', () => {
|
||||
expect(after.viewport).toContain('AFTER_SIGINT')
|
||||
expect(after.waitReason).toBe('stdin_read')
|
||||
await ctx.pty.kill(agent, created.sessionId)
|
||||
}, 10_000)
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
@@ -229,33 +229,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7
|
||||
describe('TUI terminal-state snapshots', () => {
|
||||
it('pins an in-flight reasoning and Markdown stream', async () => {
|
||||
const harness = await setupSnapshot()
|
||||
await renderAfter(harness, () => {
|
||||
harness.agent.status = 'running'
|
||||
harness.ctx.emit('agent/status', harness.agent, 'running')
|
||||
appendUser(harness.session, 'Show the live update.')
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
// Freeze the loader's first animation interval so this semantic snapshot
|
||||
// cannot select a different spinner frame under scheduler contention.
|
||||
const frozenLoaderTimer = setInterval(() => {}, 60_000)
|
||||
const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer)
|
||||
try {
|
||||
await renderAfter(harness, () => {
|
||||
harness.agent.status = 'running'
|
||||
harness.ctx.emit('agent/status', harness.agent, 'running')
|
||||
appendUser(harness.session, 'Show the live update.')
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
|
||||
})
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
|
||||
})
|
||||
})
|
||||
await checkpoint('conversation-streaming', harness.terminal)
|
||||
await disposeSnapshot(harness)
|
||||
const loaderIntervalMs = intervals.mock.calls[0]?.[1]
|
||||
if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval')
|
||||
await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5))
|
||||
await checkpoint('conversation-streaming', harness.terminal)
|
||||
} finally {
|
||||
intervals.mockRestore()
|
||||
clearInterval(frozenLoaderTimer)
|
||||
await disposeSnapshot(harness)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
|
||||
|
||||
@@ -1,6 +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
|
||||
README.md: 5e567115c386d14b7e412ed2502e7290826a5e5e
|
||||
README.zh.md: b17fe4107908381806d4029481bbf03696c4f313
|
||||
# pnpm run verify-translation-pairing --write packages/web/tool-web/README.md
|
||||
README.md: 9b78920b1b6c611118294421dec1e75e381ed5d6
|
||||
README.zh.md: 2152c40f1ccac2272fa0b2681514a712417c0ad3
|
||||
|
||||
@@ -11,7 +11,7 @@ Each tool is registered independently; a product that wants only one disables th
|
||||
| Tool | Args | Behavior |
|
||||
|---|---|---|
|
||||
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
|
||||
| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. |
|
||||
| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown (turndown with GFM tables/strikethrough); text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. |
|
||||
|
||||
Both tools opt into concurrent scheduling because provider reads return content without mutating parent-agent state.
|
||||
|
||||
@@ -26,8 +26,9 @@ The normalized seam results are also the canonical tool values: `WebSearchResult
|
||||
| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). |
|
||||
| `fetchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_fetch`. |
|
||||
| `searchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_search`. |
|
||||
| `fetchMaxOutputChars` | `200000` | Cap on source characters converted synchronously and on one complete `web_fetch` output (header, rendered body, and footer); a cut body gets the truncation notice when it fits. |
|
||||
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument.
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument. `fetchMaxOutputChars` bounds both synchronous conversion work and the complete rendered result: only that many source characters are converted, and the header, converted prefix, and truncation notice are then capped together. The default leaves headroom above the local provider's 100,000-character body cap, but rendered expansion can still make the final bound truncate the result.
|
||||
|
||||
```yaml
|
||||
- id: tool-web
|
||||
@@ -126,6 +127,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`htmlToMarkdown` is a minimal regex converter, not an HTML parser** — it strips script/style/noscript, keeps headings/bullets/links, and decodes about a dozen named entities; tables, images, and nested formatting are lost.
|
||||
- **HTML→markdown conversion degrades on inputs GFM cannot safely represent** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts at most `fetchMaxOutputChars` source characters through a real DOM. A conservative 512-level lexical guard passes deeply or ambiguously nested bodies through as raw HTML, conversion exceptions do the same, and table `colspan` is ignored because GFM has no spanning-cell representation; these bounds avoid blocking the event loop or expanding output from an untrusted numeric attribute ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)).
|
||||
- **The model-facing surface is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md).
|
||||
- **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| 工具 | 参数 | 行为 |
|
||||
|---|---|---|
|
||||
| `web_search` | `query`(string) | 发现。返回可选答案与源 URL。`max_results` **不** 面向模型:工具设置上限(`searchMaxResults` 配置,默认 8)并传给 seam。 |
|
||||
| `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为近似 markdown 的文本;文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-timeout-policy`),不是模型参数。 |
|
||||
| `web_fetch` | `url`(string) | 获取特定 URL。HTML 主体渲染为 markdown(turndown,带 GFM 表格/删除线);文本主体原样通过。非 2xx 状态会报告,而非报错。工具调用超时是部署策略(`dsh-timeout-policy`),不是模型参数。 |
|
||||
|
||||
两个工具都选择并发调度,因为提供方读取会返回内容,不会修改父 agent 状态。
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
| `searchMaxResults` | `8` | 一次 `web_search` 调用返回的源数量上限(seam 截断更长的提供方列表并标记)。 |
|
||||
| `fetchTimeoutMs` | `30000` | `web_fetch` 的协作式工具调用超时预算(ms)。 |
|
||||
| `searchTimeoutMs` | `30000` | `web_search` 的协作式工具调用超时预算(ms)。 |
|
||||
| `fetchMaxOutputChars` | `200000` | 同步转换的源字符数与单次完整 `web_fetch` 输出的上限(状态头、渲染后的主体与页脚合并计算);主体被截断时,在能容纳的情况下附带截断提示。 |
|
||||
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。
|
||||
`fetchTimeoutMs`/`searchTimeoutMs` 声明每个工具的协作式超时预算(附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) 强制执行;面向模型的 schema 不公开超时参数。`fetchMaxOutputChars` 同时限制同步转换工作量和完整渲染结果:只转换至多该数量的源字符,随后对状态头、转换后的前缀和截断提示合并设限。默认值为本地提供方的 100,000 字符主体上限留出余量,但渲染膨胀仍可能使最终上限截断结果。
|
||||
|
||||
```yaml
|
||||
- id: tool-web
|
||||
@@ -126,6 +127,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`htmlToMarkdown` 是最小正则转换器,不是 HTML parser**:它会移除 script/style/noscript,保留标题/项目符号/链接,并解码约十余个命名 entity;表格、图片与嵌套格式会丢失。
|
||||
- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([决策记录](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。
|
||||
- **面向模型的表层有意保持最小,提升项暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM 摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。
|
||||
- **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久 URL/domain 授权。
|
||||
|
||||
@@ -35,10 +35,13 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.67",
|
||||
"schemastery": "^3.18.0",
|
||||
"turndown": "^7.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -6,12 +6,75 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import TurndownService from 'turndown'
|
||||
import { gfm } from '@joplin/turndown-plugin-gfm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { htmlToMarkdown } from './html.ts'
|
||||
|
||||
/**
|
||||
* The shared HTML→markdown converter: turndown over its bundled domino DOM,
|
||||
* with GitHub-flavored tables/strikethrough (`@joplin/turndown-plugin-gfm`).
|
||||
* The style options are fixed model-facing presentation (matching the repo's
|
||||
* markdown conventions), not deployment tunables. `remove` drops non-content
|
||||
* elements wholesale — turndown's default keeps their text. The instance is
|
||||
* stateless across `turndown()` calls and safe to share.
|
||||
*/
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
})
|
||||
turndown.use(gfm)
|
||||
turndown.remove(['script', 'style', 'noscript'])
|
||||
|
||||
/** Render one GFM table cell without interpreting HTML span counts. */
|
||||
function renderTableCell(content: string, index: number): string {
|
||||
const prefix = index === 0 ? '| ' : ' '
|
||||
const escaped = content.trim().replace(/\n\r/g, '<br>').replace(/\n/g, '<br>').replace(/\|+/g, '\\|').padEnd(3, ' ')
|
||||
return `${prefix}${escaped} |`
|
||||
}
|
||||
|
||||
/** Whether a row is the table's Markdown heading row. */
|
||||
function isTableHeadingRow(row: HTMLTableRowElement): boolean {
|
||||
const cells = Array.from(row.cells)
|
||||
const section = row.parentElement as HTMLTableSectionElement
|
||||
const table = section.parentElement as HTMLTableElement
|
||||
return (section.nodeName === 'THEAD' || table.rows[0] === row)
|
||||
&& cells.every(cell => cell.nodeName === 'TH')
|
||||
}
|
||||
|
||||
/** Map an HTML table-cell alignment to the GFM separator marker. */
|
||||
function tableBorder(cell: HTMLTableCellElement): string {
|
||||
const alignment = (cell.getAttribute('align') || cell.style.textAlign || '').toLowerCase()
|
||||
if (alignment === 'left') return ':---'
|
||||
if (alignment === 'right') return '---:'
|
||||
if (alignment === 'center') return ':---:'
|
||||
return '---'
|
||||
}
|
||||
|
||||
turndown.addRule('tableCellWithoutSpanExpansion', {
|
||||
filter: ['th', 'td'],
|
||||
replacement(content, node) {
|
||||
const cell = node as HTMLTableCellElement
|
||||
const row = cell.parentNode as HTMLTableRowElement
|
||||
// GFM cannot represent spanning cells. Ignoring colspan keeps conversion
|
||||
// work and output proportional to the source instead of the numeric attribute.
|
||||
return renderTableCell(content, Array.prototype.indexOf.call(row.childNodes, cell))
|
||||
},
|
||||
})
|
||||
turndown.addRule('tableRowWithoutSpanExpansion', {
|
||||
filter: 'tr',
|
||||
replacement(content, node) {
|
||||
const row = node as HTMLTableRowElement
|
||||
const border = isTableHeadingRow(row)
|
||||
? Array.from(row.cells, (cell, index) => renderTableCell(tableBorder(cell), index)).join('')
|
||||
: ''
|
||||
return `\n${content}${border.length > 0 ? `\n${border}` : ''}`
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank `url`.
|
||||
@@ -27,36 +90,182 @@ export function parseFetchArgs(args: { url: string }): { url: string } {
|
||||
return { url: args.url }
|
||||
}
|
||||
|
||||
/**
|
||||
* Nesting-depth ceiling above which HTML skips conversion and passes through
|
||||
* raw. Conversion runs synchronously on the event loop, and unclosed-tag
|
||||
* nesting makes domino's tree (and turndown's walk over it) superlinear —
|
||||
* measured: depth 512 ≈ 0.15s, 2,000 ≈ 2s, 20,000 ≈ 5s — during which the
|
||||
* cooperative `fetchTimeoutMs` timer cannot fire. Real pages nest a few dozen
|
||||
* levels; 512 is far above content and far below weaponizable. A robustness
|
||||
* invariant, not a tunable.
|
||||
*/
|
||||
const MAX_CONVERSION_DEPTH = 512
|
||||
|
||||
/** Elements that never take a closing tag, so they do not grow the lexical stack. */
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
||||
])
|
||||
|
||||
/** Elements whose contents HTML parses as text until their matching end tag. */
|
||||
const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'noscript'])
|
||||
|
||||
/** Whether a character can occur after a raw-text end-tag name. */
|
||||
function isTagBoundary(char: string | undefined): boolean {
|
||||
return char === undefined || char === '>' || char === '/' || /\s/.test(char)
|
||||
}
|
||||
|
||||
/** Find the matching raw-text end tag without interpreting markup-like body text. */
|
||||
function findRawTextEnd(lowerHtml: string, name: string, from: number): number {
|
||||
const prefix = `</${name}`
|
||||
let candidate = lowerHtml.indexOf(prefix, from)
|
||||
while (candidate !== -1 && !isTagBoundary(lowerHtml[candidate + prefix.length])) {
|
||||
candidate = lowerHtml.indexOf(prefix, candidate + prefix.length)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservatively reject HTML whose lexical element stack crosses the conversion
|
||||
* depth ceiling. The single pass ignores closing tags inside comments, skips
|
||||
* raw-text bodies, respects quoted `>` characters, and only accepts a closing
|
||||
* tag for the current element; malformed input therefore over-counts rather
|
||||
* than hiding nesting.
|
||||
*
|
||||
* @param html - the decoded HTML body.
|
||||
* @returns whether the body crosses {@link MAX_CONVERSION_DEPTH}.
|
||||
*/
|
||||
function exceedsConversionDepth(html: string): boolean {
|
||||
const lowerHtml = html.toLowerCase()
|
||||
const openElements: string[] = []
|
||||
let offset = 0
|
||||
let inComment = false
|
||||
|
||||
while (offset < html.length) {
|
||||
const start = html.indexOf('<', offset)
|
||||
if (inComment) {
|
||||
const end = html.indexOf('-->', offset)
|
||||
if (end !== -1 && (start === -1 || end < start)) {
|
||||
inComment = false
|
||||
offset = end + 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (start === -1) break
|
||||
if (!inComment && html.startsWith('<!--', start)) {
|
||||
inComment = true
|
||||
offset = start + 4
|
||||
continue
|
||||
}
|
||||
|
||||
let cursor = start + 1
|
||||
const closing = html[cursor] === '/'
|
||||
if (closing) cursor += 1
|
||||
const nameStart = cursor
|
||||
while (/[a-zA-Z0-9-]/.test(html[cursor] ?? '')) cursor += 1
|
||||
if (cursor === nameStart || !/[a-zA-Z]/.test(html.charAt(nameStart))) {
|
||||
offset = start + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const name = lowerHtml.slice(nameStart, cursor)
|
||||
let quote: '"' | "'" | undefined
|
||||
while (cursor < html.length) {
|
||||
const char = html[cursor]
|
||||
cursor += 1
|
||||
if (quote !== undefined) {
|
||||
if (char === quote) quote = undefined
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char
|
||||
} else if (char === '>') {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (html[cursor - 1] !== '>') break
|
||||
|
||||
if (closing) {
|
||||
if (!inComment && openElements.at(-1) === name) openElements.pop()
|
||||
} else {
|
||||
let last = cursor - 2
|
||||
while (/\s/.test(html.charAt(last))) last -= 1
|
||||
if (!VOID_ELEMENTS.has(name) && html[last] !== '/') {
|
||||
openElements.push(name)
|
||||
if (openElements.length > MAX_CONVERSION_DEPTH) return true
|
||||
if (!inComment && RAW_TEXT_ELEMENTS.has(name)) {
|
||||
const end = findRawTextEnd(lowerHtml, name, cursor)
|
||||
if (end === -1) break
|
||||
offset = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
offset = cursor
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface RenderedBody {
|
||||
/** Converted text, or raw HTML when conversion is unsafe or fails. */
|
||||
text: string
|
||||
/** Whether the source was cut before conversion to bound synchronous work. */
|
||||
sourceTruncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a fetched body to model-facing markdown text.
|
||||
*
|
||||
* @param body - the decoded body; `html` is converted via
|
||||
* {@link htmlToMarkdown}, `text` passes through verbatim.
|
||||
* @returns the text for the tool's output block.
|
||||
* @param body - the decoded body; `html` is converted via turndown, `text`
|
||||
* passes through verbatim.
|
||||
* @param maxInputChars - maximum source characters processed synchronously.
|
||||
* @returns the rendered prefix and whether the source was cut. HTML nested
|
||||
* beyond {@link MAX_CONVERSION_DEPTH} or rejected by turndown passes through
|
||||
* raw; a degraded page beats an error for a body the provider decoded.
|
||||
*/
|
||||
export function renderBody(body: WebFetchBody): string {
|
||||
function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody {
|
||||
const content = body.content.slice(0, maxInputChars)
|
||||
const sourceTruncated = content.length !== body.content.length
|
||||
switch (body.kind) {
|
||||
case 'html':
|
||||
return htmlToMarkdown(body.content)
|
||||
if (exceedsConversionDepth(content)) return { text: content, sourceTruncated }
|
||||
try {
|
||||
return { text: turndown.turndown(content), sourceTruncated }
|
||||
} catch {
|
||||
// turndown's DOM walk recurses per element; malformed markup the lexical
|
||||
// guard cannot model can still throw RangeError. Provider errors stay
|
||||
// structured WebErrors upstream; conversion failure downgrades to raw HTML.
|
||||
return { text: content, sourceTruncated }
|
||||
}
|
||||
case 'text':
|
||||
return body.content
|
||||
return { text: content, sourceTruncated }
|
||||
/* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */
|
||||
default:
|
||||
return assertNever(body, 'unhandled web fetch body kind')
|
||||
}
|
||||
}
|
||||
|
||||
/** The truncation notice appended when the provider or the output cap cut content. */
|
||||
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block.
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
* The same cap limits the source prefix processed synchronously, then applies
|
||||
* again where the complete output — header, rendered body, and footer — is known.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string; a cut body gets
|
||||
* the same fetch-something-narrower notice as provider-side truncation.
|
||||
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
|
||||
* fetch-something-narrower notice when the provider truncated the content.
|
||||
* truncation notice when the provider or the cap cut the content.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult): string {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})`
|
||||
const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : ''
|
||||
return `${header}\n\n${renderBody(result.body)}${footer}`
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`
|
||||
const rendered = renderBody(result.body, maxOutputChars)
|
||||
const prefix = `${header}${rendered.text}`
|
||||
const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars
|
||||
const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}`
|
||||
if (full.length <= maxOutputChars) return full
|
||||
if (maxOutputChars < TRUNCATION_FOOTER.length) return full.slice(0, maxOutputChars)
|
||||
return `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,8 +285,10 @@ export function presentFetchCall(args: { url: string }): GenericCallView {
|
||||
* registrations; both are effect-scoped and unregister on plugin dispose.
|
||||
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
|
||||
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
|
||||
* @param maxOutputChars - cap on the complete rendered tool output (see
|
||||
* {@link formatFetchOutput}) and on source characters converted synchronously.
|
||||
*/
|
||||
export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
|
||||
export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChars: number): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_fetch',
|
||||
order: 111,
|
||||
@@ -121,7 +332,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
|
||||
truncated: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Minimal dependency-free HTML-to-readable-text conversion for `web_fetch`, not a full parser. It
|
||||
* removes non-content elements and tags, decodes common entities, collapses whitespace, and keeps
|
||||
* basic headings, lists, and links. A richer converter can replace it without changing the seam or
|
||||
* tool schema.
|
||||
* @module @deepseek-ai/dsh-tool-web/html
|
||||
*/
|
||||
|
||||
/** Decode the handful of HTML entities common in textual content. */
|
||||
function decodeEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, entity: string) => {
|
||||
if (entity.startsWith('#x') || entity.startsWith('#X')) {
|
||||
const code = Number.parseInt(entity.slice(2), 16)
|
||||
return safeFromCodePoint(code, match)
|
||||
}
|
||||
if (entity.startsWith('#')) {
|
||||
const code = Number.parseInt(entity.slice(1), 10)
|
||||
return safeFromCodePoint(code, match)
|
||||
}
|
||||
return NAMED_ENTITIES[entity] ?? match
|
||||
})
|
||||
}
|
||||
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
||||
copy: '©', reg: '®', trade: '™', hellip: '…', mdash: '—', ndash: '–',
|
||||
}
|
||||
|
||||
function safeFromCodePoint(code: number, fallback: string): string {
|
||||
try {
|
||||
return String.fromCodePoint(code)
|
||||
} catch {
|
||||
// An out-of-range code point (RangeError) is the only failure here; keep the
|
||||
// original entity text rather than throwing out of pure presentation.
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an HTML document to a readable markdown-ish text approximation.
|
||||
* Best-effort and lossy by design — fidelity is the job of a future heavier
|
||||
* converter, not this fallback.
|
||||
*
|
||||
* @param html - the raw HTML source.
|
||||
* @returns plain text with markdown headings, list bullets, and links;
|
||||
* whitespace collapsed to at most one blank line and trimmed.
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
let text = html
|
||||
// Drop non-content elements entirely (including their contents).
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
|
||||
// Convert links to markdown before stripping tags.
|
||||
text = text.replace(/<a\b[^>]*\bhref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_match, href: string, label: string) => {
|
||||
const cleanLabel = label.replace(/<[^>]+>/g, '').trim()
|
||||
return cleanLabel.length > 0 ? `[${cleanLabel}](${href})` : href
|
||||
})
|
||||
|
||||
// Headings → markdown hashes.
|
||||
text = text.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_match, level: string, body: string) => {
|
||||
const hashes = '#'.repeat(Number(level))
|
||||
return `\n\n${hashes} ${body.replace(/<[^>]+>/g, '').trim()}\n\n`
|
||||
})
|
||||
|
||||
// List items → bullets.
|
||||
text = text.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, body: string) => `\n- ${body.replace(/<[^>]+>/g, '').trim()}`)
|
||||
|
||||
// Block-level breaks become paragraph breaks.
|
||||
text = text
|
||||
.replace(/<\/(p|div|section|article|header|footer|tr|table|ul|ol|blockquote)>/gi, '\n\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
|
||||
// Drop all remaining tags, decode entities, collapse whitespace.
|
||||
text = text.replace(/<[^>]+>/g, '')
|
||||
text = decodeEntities(text)
|
||||
text = text
|
||||
.replace(/[ \t\f\v]+/g, ' ')
|
||||
.replace(/ *\n */g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
return text
|
||||
}
|
||||
@@ -13,8 +13,7 @@ import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts'
|
||||
import { applyWebFetchTool } from './fetch.ts'
|
||||
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts'
|
||||
export { htmlToMarkdown } from './html.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall } from './fetch.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-web'
|
||||
@@ -25,7 +24,14 @@ export const inject = ['tools', 'web', 'systemPrompt']
|
||||
/** Default cooperative tool-call timeout budget (ms) for the web tools. */
|
||||
export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */
|
||||
/**
|
||||
* Default cap on one `web_fetch` output and on source characters converted
|
||||
* synchronously. This leaves headroom above the local provider's default
|
||||
* 100,000-character body cap while bounding custom providers and rendered output.
|
||||
*/
|
||||
export const DEFAULT_FETCH_MAX_OUTPUT_CHARS = 200_000
|
||||
|
||||
/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
|
||||
export interface Config {
|
||||
/** Register `web_search`. Defaults to true. */
|
||||
search?: boolean
|
||||
@@ -37,6 +43,8 @@ export interface Config {
|
||||
fetchTimeoutMs?: number
|
||||
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
|
||||
searchTimeoutMs?: number
|
||||
/** Cap on source characters converted and complete `web_fetch` output characters. Defaults to 200000. */
|
||||
fetchMaxOutputChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -45,12 +53,13 @@ export const Config: z<Config> = z.object({
|
||||
searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS),
|
||||
fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
|
||||
searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
|
||||
fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applies its defaults to every field. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** The result cap must be a positive integer (it bounds a provider's source list). */
|
||||
/** Configured count, timeout, and character caps must be positive integers. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-web: ${name} must be a positive integer`)
|
||||
@@ -72,6 +81,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
|
||||
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
|
||||
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
|
||||
assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars)
|
||||
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
|
||||
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs)
|
||||
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars)
|
||||
}
|
||||
|
||||
12
packages/web/tool-web/src/turndown-plugin-gfm.d.ts
vendored
Normal file
12
packages/web/tool-web/src/turndown-plugin-gfm.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Ambient module declaration for `@joplin/turndown-plugin-gfm`, which ships no
|
||||
* types and has no DefinitelyTyped package. Only the composite `gfm` plugin is
|
||||
* declared; the package's individual plugins (`tables`, `strikethrough`, …)
|
||||
* stay undeclared until something imports them.
|
||||
*/
|
||||
declare module '@joplin/turndown-plugin-gfm' {
|
||||
import type TurndownService from 'turndown'
|
||||
|
||||
/** The composite GitHub-flavored-markdown plugin (tables, strikethrough, task lists, highlighted code blocks). */
|
||||
export const gfm: TurndownService.Plugin
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import TurndownService from 'turndown'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
@@ -13,8 +14,6 @@ import {
|
||||
parseFetchArgs,
|
||||
presentSearchCall,
|
||||
presentFetchCall,
|
||||
renderBody,
|
||||
htmlToMarkdown,
|
||||
WEB_SEARCH_MAX_RESULTS,
|
||||
} from '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
@@ -82,17 +81,29 @@ describe('search formatting', () => {
|
||||
expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' })
|
||||
})
|
||||
|
||||
it('falls back to the raw URL as a source label when the URL is unparseable', () => {
|
||||
const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
|
||||
expect(out).toContain('[not a url](not a url)')
|
||||
})
|
||||
|
||||
it('presents a search call as a search-kind card titled by the query', () => {
|
||||
expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetch formatting', () => {
|
||||
const NO_CAP = 1_000_000
|
||||
const HEADER = 'Fetched https://a.test (HTTP 200)\n\n'
|
||||
const renderHtml = (content: string) => formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content },
|
||||
}, NO_CAP).slice(HEADER.length)
|
||||
|
||||
it('renders an html body to markdown text with a status header', () => {
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
|
||||
})
|
||||
}, NO_CAP)
|
||||
expect(out).toContain('Fetched https://a.test (HTTP 200)')
|
||||
expect(out).toContain('# Title')
|
||||
expect(out).toContain('Body text')
|
||||
@@ -102,14 +113,140 @@ describe('fetch formatting', () => {
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: true,
|
||||
body: { kind: 'text', content: 'plain' },
|
||||
})
|
||||
}, NO_CAP)
|
||||
expect(out).toContain('plain')
|
||||
expect(out).toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('renderBody dispatches on kind', () => {
|
||||
expect(renderBody({ kind: 'text', content: 'x' })).toBe('x')
|
||||
expect(renderBody({ kind: 'html', content: '<p>y</p>' })).toBe('y')
|
||||
it('caps the complete output and notes truncation, even when markdown escaping expands the body', () => {
|
||||
// 1,000 underscores render as 2,000 escaped characters — conversion can
|
||||
// outgrow a provider-side body cap, so the bound applies to the output.
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: `<p>${'_'.repeat(1000)}</p>` },
|
||||
}, 500)
|
||||
expect(out.length).toBeLessThanOrEqual(500)
|
||||
expect(out).toContain('Fetched https://a.test (HTTP 200)')
|
||||
expect(out).toContain('\\_\\_')
|
||||
expect(out).toContain('Content truncated')
|
||||
// Exact and tiny caps: the complete result is bounded, header and footer included.
|
||||
const exact = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'text', content: 'abc' },
|
||||
}, 'Fetched https://a.test (HTTP 200)\n\nabc'.length)
|
||||
expect(exact).toBe('Fetched https://a.test (HTTP 200)\n\nabc')
|
||||
const tiny = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: true,
|
||||
body: { kind: 'text', content: 'abcdef' },
|
||||
}, 10)
|
||||
expect(tiny.length).toBeLessThanOrEqual(10)
|
||||
expect(tiny).toBe('Fetched ht')
|
||||
})
|
||||
|
||||
it('dispatches text and html bodies', () => {
|
||||
expect(formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'text', content: 'x' },
|
||||
}, NO_CAP)).toBe(`${HEADER}x`)
|
||||
expect(renderHtml('<p>y</p>')).toBe('y')
|
||||
})
|
||||
|
||||
it('converts html via turndown: entities, links, tables, nesting; drops script/style/noscript', () => {
|
||||
expect(renderHtml('<style>.x{}</style><script>bad()</script><noscript>ns</noscript><p>Tom & Jerry © Résumé</p><a href="https://a.test">link</a>'))
|
||||
.toBe('Tom & Jerry © Résumé\n\n[link](https://a.test)')
|
||||
expect(renderHtml('<h2>Heading</h2><ul><li>one</li><li>two</li></ul>'))
|
||||
.toBe('## Heading\n\n- one\n- two')
|
||||
expect(renderHtml('<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>'))
|
||||
.toBe('| A | B |\n| --- | --- |\n| 1 | 2 |')
|
||||
expect(renderHtml('<table><thead><tr><th align="left">L</th><th align="right">R</th><th style="text-align:center">C</th></tr></thead><tbody><tr><td>1</td><td>2</td><td>3</td></tr></tbody></table>'))
|
||||
.toBe('| L | R | C |\n| :--- | ---: | :---: |\n| 1 | 2 | 3 |')
|
||||
expect(renderHtml('<p><strong>bold <em>italic</em></strong></p><blockquote><p>quoted</p></blockquote>'))
|
||||
.toBe('**bold _italic_**\n\n> quoted')
|
||||
})
|
||||
|
||||
it('does not expand numeric colspan attributes into unbounded output', () => {
|
||||
const table = '<table><thead><tr><th colspan="1000000">A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'
|
||||
expect(renderHtml(table)).toBe('| A |\n| --- |\n| B |')
|
||||
})
|
||||
|
||||
it('passes deeply nested html through raw without attempting conversion', () => {
|
||||
// Unclosed-tag nesting makes the synchronous conversion superlinear
|
||||
// (seconds at 20k levels, during which the cooperative timeout cannot
|
||||
// fire), so the depth preflight skips conversion entirely; this must
|
||||
// return fast, not merely not-throw.
|
||||
const depth = 20_000
|
||||
const pathological = '<div>'.repeat(depth) + 'x' + '</div>'.repeat(depth)
|
||||
const started = Date.now()
|
||||
expect(formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: pathological },
|
||||
}, NO_CAP)).toBe(`${HEADER}${pathological}`)
|
||||
expect(Date.now() - started).toBeLessThan(2_000)
|
||||
})
|
||||
|
||||
it('comments and mismatched closing tags cannot hide deep nesting from the preflight', () => {
|
||||
const pathological = '<div><!-- </div> --></span>'.repeat(600) + 'x'
|
||||
expect(formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: pathological },
|
||||
}, NO_CAP)).toBe(`${HEADER}${pathological}`)
|
||||
const abruptlyClosedComments = '<div><!-->'.repeat(600) + 'x'
|
||||
expect(formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: abruptlyClosedComments },
|
||||
}, NO_CAP)).toBe(`${HEADER}${abruptlyClosedComments}`)
|
||||
})
|
||||
|
||||
it('the preflight accepts ordinary closed, void, self-closing, quoted, and raw-text markup', () => {
|
||||
const paragraphs = '<p title=\'>\'>x<br ><img src="x"><input/></p>'.repeat(600)
|
||||
const script = `<script>const invalid = '</scriptx>'; const template = '${'<div>'.repeat(600)}'</script >`
|
||||
expect(renderHtml(`<!doctype html><?pi><1bad>${paragraphs}${script}`))
|
||||
.not.toContain('<p')
|
||||
expect(renderHtml('plain text')).toBe('plain text')
|
||||
expect(renderHtml('<p>x</p><!-- unfinished')).toBe('x')
|
||||
expect(renderHtml('<script>unclosed')).toBe('')
|
||||
expect(renderHtml('<script>closed by slash</script/>')).toBe('')
|
||||
expect(renderHtml('<script>closed at end</script')).toBe('')
|
||||
})
|
||||
|
||||
it('scans malformed unterminated tags in bounded time', () => {
|
||||
const malformed = '<a'.repeat(100_000)
|
||||
const started = Date.now()
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: malformed },
|
||||
}, 200_000)
|
||||
expect(out.length).toBeLessThanOrEqual(200_000)
|
||||
expect(Date.now() - started).toBeLessThan(2_000)
|
||||
})
|
||||
|
||||
it('falls back to the raw html when turndown throws despite a shallow depth scan', () => {
|
||||
const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockImplementation(() => {
|
||||
throw new RangeError('Maximum call stack size exceeded')
|
||||
})
|
||||
try {
|
||||
expect(formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: '<p>x</p>' },
|
||||
}, NO_CAP)).toBe(`${HEADER}<p>x</p>`)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds source conversion work before rendering a custom provider body', () => {
|
||||
const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockReturnValue('converted')
|
||||
try {
|
||||
const out = formatFetchOutput({
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html', content: `<p>${'x'.repeat(10_000)}</p>` },
|
||||
}, 500)
|
||||
expect(spy).toHaveBeenCalledWith(`<p>${'x'.repeat(497)}`)
|
||||
expect(out.length).toBeLessThanOrEqual(500)
|
||||
expect(out).toContain('Content truncated')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('validates url (non-empty), no timeout parameter', () => {
|
||||
@@ -122,46 +259,6 @@ describe('fetch formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('htmlToMarkdown', () => {
|
||||
it('drops scripts/styles, keeps text, decodes entities, converts links', () => {
|
||||
const md = htmlToMarkdown('<style>.x{}</style><script>bad()</script><p>Tom & Jerry</p><a href="https://a.test">link</a>')
|
||||
expect(md).not.toContain('bad()')
|
||||
expect(md).not.toContain('.x{}')
|
||||
expect(md).toContain('Tom & Jerry')
|
||||
expect(md).toContain('[link](https://a.test)')
|
||||
})
|
||||
|
||||
it('decodes numeric entities and collapses whitespace', () => {
|
||||
expect(htmlToMarkdown('<p>a'b</p>')).toBe("a'b")
|
||||
expect(htmlToMarkdown('<div>x</div>\n\n\n<div>y</div>')).toBe('x\n\ny')
|
||||
})
|
||||
|
||||
it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => {
|
||||
expect(htmlToMarkdown('<p>AB</p>')).toBe('AB')
|
||||
expect(htmlToMarkdown('<p>© —</p>')).toBe('© —')
|
||||
expect(htmlToMarkdown('<p>¬areal;</p>')).toBe('¬areal;')
|
||||
// An out-of-range code point keeps the original entity text (fromCodePoint fallback).
|
||||
expect(htmlToMarkdown('<p>�</p>')).toBe('�')
|
||||
expect(htmlToMarkdown('<p>�</p>')).toBe('�')
|
||||
})
|
||||
|
||||
it('renders a link with an empty label as its bare href', () => {
|
||||
expect(htmlToMarkdown('<a href="https://a.test"></a>')).toBe('https://a.test')
|
||||
})
|
||||
|
||||
it('converts headings and list items to markdown', () => {
|
||||
expect(htmlToMarkdown('<h2>Heading</h2><p>after</p>')).toContain('## Heading')
|
||||
const list = htmlToMarkdown('<ul><li>one</li><li>two</li></ul>')
|
||||
expect(list).toContain('- one')
|
||||
expect(list).toContain('- two')
|
||||
})
|
||||
|
||||
it('falls back to the raw URL as a source label when the URL is unparseable', () => {
|
||||
const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
|
||||
expect(out).toContain('[not a url](not a url)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-web registration', () => {
|
||||
it('registers both tools by default', async () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
@@ -395,3 +492,35 @@ describe('tool-call timeout budget is plugin config', () => {
|
||||
.rejects.toThrow(new RegExp(`tool-web: ${key} must be a positive integer`))
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMaxOutputChars is plugin config', () => {
|
||||
it('bounds the rendered output of the registered web_fetch tool', async () => {
|
||||
const fetchProvider = {
|
||||
id: 'stub-fetch',
|
||||
available: () => available,
|
||||
fetch: (request: { url: string }) => Promise.resolve({
|
||||
url: request.url,
|
||||
statusCode: 200,
|
||||
body: { kind: 'html' as const, content: `<p>${'_'.repeat(1_000)}</p>` },
|
||||
truncated: false,
|
||||
}),
|
||||
}
|
||||
const { fiber, call } = await mountTools({
|
||||
config: { fetchMaxOutputChars: 100 },
|
||||
webConfig: { fetchProvider: 'stub-fetch' },
|
||||
fetchProvider,
|
||||
})
|
||||
const out = await call('web_fetch', { url: 'https://a.test' })
|
||||
expect(out.content.map(block => block.type === 'text' ? block.text : '').join('')).toHaveLength(100)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5])('rejects an invalid fetchMaxOutputChars value %s at load', async (value) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(WebService, {})
|
||||
await expect(ctx.plugin(ToolWeb, { fetchMaxOutputChars: value }))
|
||||
.rejects.toThrow(/tool-web: fetchMaxOutputChars must be a positive integer/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +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
|
||||
README.md: 0d5ebabfbbb2922a369adb3a5d67ea4aafbe700f
|
||||
README.zh.md: b82e8e6138f3e97c3c047cf1812cee8e558ea29b
|
||||
# pnpm run verify-translation-pairing --write packages/workspace/README.md
|
||||
README.md: ba92e95d3cde0a95eaaeae5a9b4384c3b8c9c4b8
|
||||
README.zh.md: 8c8146bba5fa6d81c0ce5d2ed29add77b4083270
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` |
|
||||
|
||||
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives.
|
||||
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deleting a Workspace removes only this registry record and account: directories, user files, and session logs remain, and the Sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
|
||||
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `workspace/` | 位于存储领域形式之上的 `WorkspaceRegistry` 服务:按 realpath 唯一的路径、会话所有权计数、实体缓存 | `ctx.workspace` |
|
||||
|
||||
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。本阶段有意不提供删除(workspace 与会话级联);该功能将与会话侧原语一起交付。
|
||||
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。删除 Workspace 只会移除该注册表记录及账本:目录、用户文件和会话日志都会保留,相关会话则进入 Ungrouped(参见[决策记录](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。
|
||||
|
||||
@@ -1,6 +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
|
||||
README.md: 0d395ecc58fc5e3362cb5f3c565a0539bb09c4dd
|
||||
README.zh.md: 017e1e4d3aae9f8708ead3565f8b5b59d9b249ca
|
||||
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
|
||||
README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62
|
||||
README.zh.md: 7960a2d13df4f881687fd88cdb07e237b3abb7c8
|
||||
|
||||
@@ -10,6 +10,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
|
||||
|
||||
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title.
|
||||
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
|
||||
- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity.
|
||||
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.
|
||||
- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes.
|
||||
- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup.
|
||||
@@ -17,6 +18,8 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
|
||||
|
||||
`storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped.
|
||||
|
||||
Create and delete persist an explicit pending-mutation marker before their record and order can diverge. Startup completes only the marked mutation, then clears the marker; an unmarked order/table mismatch remains unexplained corruption and fails loud. Deleting and re-registering the same path creates a fresh Workspace id and does not automatically re-adopt the retained Sessions.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Workspace records and session accounts
|
||||
@@ -35,5 +38,5 @@ Independent of live requests: the package never touches a request prefix, so it
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed.
|
||||
- Session deletion and destructive folder removal are separate, absent capabilities; Workspace registration deletion never substitutes for either ([decision](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
|
||||
- The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart.
|
||||
|
||||
@@ -10,6 +10,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。
|
||||
- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它应用同一 `realpath` 规范,并会拒绝缺失路径,而不是创建路径。
|
||||
- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话账本。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、实时会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。
|
||||
- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。
|
||||
- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话为空操作,workspace 顺序绝不改变。
|
||||
- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、从两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。
|
||||
@@ -17,6 +18,8 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
`storageDomain` 和 `sessionPersistence` 是启动必需依赖。对等服务不可用时,插件保持待处理,且不能提交空的已初始化标记。首次成功启动时,注册表调用 `SessionPersistence.list()`,仅使用头部 `id`、`cwd` 和 `createdAt` 对有效历史目录分组并持久化初始顺序;它绝不读取事件正文。已初始化标记最后写入,因此重启后可安全复用部分启动写入。后续仅有 cwd 的会话仍属于 Ungrouped。
|
||||
|
||||
Create 与 delete 会在记录和顺序可能分叉之前,先持久化明确的待处理变更标记。启动时只补全被该标记证明的变更,随后清除标记;没有标记的顺序/表不一致仍属于来源不明的损坏,并会直接失败。删除后重新注册同一路径会生成新的 Workspace id,且不会自动重新接纳保留下来的 Session。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Workspace 记录与会话记账
|
||||
@@ -35,5 +38,5 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- 本阶段没有删除入口:workspace 删除将与会话删除原语和级联编排一起作为完整语义交付(参见 Agent Note 的未来工作一节);系统有意不公开「删除记录、保留会话」的半成品操作。
|
||||
- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。
|
||||
- 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或 cwd 破坏会在下次刷新或重启后被观测。
|
||||
|
||||
@@ -109,6 +109,7 @@ export class WorkspaceRegistry extends Service {
|
||||
this.global = domain.global
|
||||
this.state = domain.global.get()
|
||||
|
||||
await this.recoverPendingMutation()
|
||||
this.validateStoredState(this.state)
|
||||
if (!this.state.initialized) {
|
||||
const headers = await this.ctx.sessionPersistence.list()
|
||||
@@ -168,6 +169,18 @@ export class WorkspaceRegistry extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one workspace registration while retaining its directory and every
|
||||
* session log. The durable order is updated before the table deletion; a
|
||||
* failed table write restores the prior order and keeps the entity
|
||||
* published. Unknown ids are an idempotent no-op for domain callers.
|
||||
* @param id - Workspace registration to remove.
|
||||
* @returns `true` when a record was deleted, `false` when it was unknown.
|
||||
*/
|
||||
delete(id: WorkspaceId): Promise<boolean> {
|
||||
return this.enqueueOperation(() => this.deleteKnown(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
@@ -206,10 +219,28 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
const entity = new WorkspaceEntity(this.host, id, record)
|
||||
this.entities.set(id, entity)
|
||||
const pendingState: WorkspaceDomainState = {
|
||||
...state,
|
||||
pendingMutation: { operation: 'create', workspaceId: id },
|
||||
}
|
||||
try {
|
||||
await this.setState(pendingState)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await table.put(id, record)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' record write and pending-marker rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -220,10 +251,17 @@ export class WorkspaceRegistry extends Service {
|
||||
try {
|
||||
await table.delete(id)
|
||||
} catch (rollbackError) {
|
||||
this.entities.set(id, entity)
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' was stored but its registry order and rollback both failed`,
|
||||
`workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' order write and pending-marker rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
@@ -231,6 +269,69 @@ export class WorkspaceRegistry extends Service {
|
||||
return entity
|
||||
}
|
||||
|
||||
private async deleteKnown(id: WorkspaceId): Promise<boolean> {
|
||||
const entity = this.entities.get(id)
|
||||
if (entity === undefined) return false
|
||||
const state = this.requireState()
|
||||
const nextState = {
|
||||
initialized: true,
|
||||
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
|
||||
}
|
||||
await this.setState({
|
||||
...nextState,
|
||||
pendingMutation: { operation: 'delete', workspaceId: id },
|
||||
})
|
||||
this.entities.delete(id)
|
||||
try {
|
||||
await this.requireTable().delete(id)
|
||||
} catch (error) {
|
||||
this.entities.set(id, entity)
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
// The durable marker still says to finish deletion, so the cache must
|
||||
// agree with that recoverable direction rather than republish a row
|
||||
// absent from the persisted order.
|
||||
this.entities.delete(id)
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' record deletion and registry-order rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await this.setState(nextState)
|
||||
} catch (error) {
|
||||
// The deletion committed at the table write and was already published
|
||||
// to Host streams. Keep the durable marker for startup recovery rather
|
||||
// than reporting failure after the requested state became true.
|
||||
this.ctx.logger.warn(
|
||||
`workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the one mutation explicitly named by durable state. Unexplained
|
||||
* order/table divergence still reaches {@link validateStoredState} and
|
||||
* fails loud; this path never infers provenance from shape alone.
|
||||
*/
|
||||
private async recoverPendingMutation(): Promise<void> {
|
||||
const state = this.requireState()
|
||||
const pending = state.pendingMutation
|
||||
if (pending === undefined) return
|
||||
if (state.workspaceIds.includes(pending.workspaceId)) {
|
||||
throw new Error(
|
||||
`workspace domain is inconsistent: pending ${pending.operation} workspace `
|
||||
+ `'${pending.workspaceId}' is still present in registry order`,
|
||||
)
|
||||
}
|
||||
await this.requireTable().delete(pending.workspaceId)
|
||||
await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
|
||||
}
|
||||
|
||||
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
|
||||
const table = this.requireTable()
|
||||
const state = this.requireState()
|
||||
@@ -454,7 +555,12 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
|
||||
private enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operationTail.then(operation)
|
||||
const result = this.operationTail.then(async () => {
|
||||
// A committed delete may leave only its marker cleanup pending. Retry
|
||||
// recovery before another create/delete can overwrite that provenance.
|
||||
await this.recoverPendingMutation()
|
||||
return await operation()
|
||||
})
|
||||
this.operationTail = result.then(() => {}, () => {})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ export const inject = ['invariants']
|
||||
* domain's durable table. Every `domain/changed` for the `workspaces` table
|
||||
* must name a record the cache already holds an entity for (the registry
|
||||
* caches before the durable put and mutates only through cached entities).
|
||||
* A delete is valid only for create rollback, after the provisional cache
|
||||
* entry has been removed; deleting a published entity proves a bypass.
|
||||
* A delete is valid only after the registry has removed the entity from its
|
||||
* cache, whether for create rollback or an explicit registration deletion;
|
||||
* deleting while the cache still publishes the entity proves a bypass.
|
||||
*/
|
||||
const install: InvariantInstaller = Object.assign(
|
||||
(ctx: Context, fail: (message: string) => never) => {
|
||||
|
||||
@@ -29,6 +29,16 @@ export const workspaceRecord = z.object({
|
||||
/** One stored workspace record, inferred from {@link workspaceRecord}. */
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
/**
|
||||
* Recoverable two-write mutation marker. The marker is persisted before the
|
||||
* record/order pair can diverge, so startup can distinguish an interrupted
|
||||
* registry operation from unexplained medium corruption.
|
||||
*/
|
||||
const workspacePendingMutation = z.discriminatedUnion('operation', [
|
||||
z.object({ operation: z.literal('create'), workspaceId }),
|
||||
z.object({ operation: z.literal('delete'), workspaceId }),
|
||||
])
|
||||
|
||||
/**
|
||||
* Durable registry state. `initialized` distinguishes a valid empty registry
|
||||
* from one that still needs the header-only history bootstrap;
|
||||
@@ -37,6 +47,7 @@ export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
export const workspaceDomainState = z.object({
|
||||
initialized: z.boolean(),
|
||||
workspaceIds: z.array(workspaceId),
|
||||
pendingMutation: workspacePendingMutation.optional(),
|
||||
})
|
||||
|
||||
/** Durable registry state inferred from {@link workspaceDomainState}. */
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('workspace cache/table invariant', () => {
|
||||
.toThrow(/cache still publishes/)
|
||||
})
|
||||
|
||||
it('allows deletion only after a provisional create cache entry was removed for rollback', async () => {
|
||||
it('allows deletion after the registry removed the cache entry for rollback or explicit deletion', async () => {
|
||||
const ctx = await setup([])
|
||||
expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -89,7 +89,7 @@ async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = n
|
||||
/** Backend wrapper that injects one selected bootstrap write failure. */
|
||||
function selectiveFailureBackend(
|
||||
pool: MemoryMediaPool,
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number },
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number | readonly number[] },
|
||||
): StorageBackend {
|
||||
const inner = new MemoryStorageBackend(pool)
|
||||
let puts = 0
|
||||
@@ -113,7 +113,8 @@ function selectiveFailureBackend(
|
||||
},
|
||||
setGlobal: async (value) => {
|
||||
globals += 1
|
||||
if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure')
|
||||
const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt]
|
||||
if (failAt.includes(globals)) throw new Error('selected bootstrap marker failure')
|
||||
await unit.setGlobal(value)
|
||||
},
|
||||
close: () => unit.close(),
|
||||
@@ -394,19 +395,34 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
|
||||
it('rolls back the provisional cache when the record write fails', async () => {
|
||||
const dir = await makeDir('write-failure')
|
||||
const result = await harness()
|
||||
result.pool.failNextWrites = 1
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/injected/)
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap put failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(await result.registry.create(dir)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not publish a Workspace when its pending marker cannot be written', async () => {
|
||||
const dir = await makeDir('pending-marker-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')?.size ?? 0).toBe(0)
|
||||
})
|
||||
|
||||
it('rolls back a record when registry-order persistence fails', async () => {
|
||||
const dir = await makeDir('order-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
@@ -418,17 +434,129 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3, deleteAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a record write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('record-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1, globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an order write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('order-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: [3, 4] }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes only the registration and leaves its directory and session headers untouched', async () => {
|
||||
const dir = await makeDir('delete-registration')
|
||||
const result = await harness({ sessions: [header('kept-session', dir)] })
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('kept-session'))
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).resolves.toBe(true)
|
||||
await expect(result.registry.delete(workspace.id)).resolves.toBe(false)
|
||||
expect(result.registry.get(workspace.id)).toBeUndefined()
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false)
|
||||
await expect(realpath(dir)).resolves.toBe(dir)
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
expect(result.load).not.toHaveBeenCalled()
|
||||
expect(result.inspect).not.toHaveBeenCalled()
|
||||
|
||||
const reregistered = await result.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(reregistered.path).toBe(dir)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls registry order and cache back when record deletion fails', async () => {
|
||||
const dir = await makeDir('delete-rollback')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { deleteAt: 1 }),
|
||||
})
|
||||
const workspace = await result.registry.create(dir)
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).rejects.toThrow(/selected rollback delete failure/)
|
||||
expect(result.registry.get(workspace.id)).toBe(workspace)
|
||||
expect(result.registry.list()).toEqual([workspace])
|
||||
expect(storedState(pool).workspaceIds).toEqual([workspace.id])
|
||||
expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir })
|
||||
})
|
||||
|
||||
it('commits deletion and leaves a recoverable marker when marker cleanup fails', async () => {
|
||||
const dir = await makeDir('delete-marker-cleanup')
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 5 }),
|
||||
})
|
||||
const workspace = await first.registry.create(dir)
|
||||
|
||||
await expect(first.registry.delete(workspace.id)).resolves.toBe(true)
|
||||
expect(first.registry.list()).toEqual([])
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
const reregistered = await first.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [reregistered.id],
|
||||
})
|
||||
await first.fiber.dispose()
|
||||
|
||||
const restarted = await harness({ pool })
|
||||
expect(restarted.registry.list().map(item => item.id)).toEqual([reregistered.id])
|
||||
})
|
||||
|
||||
it('keeps the failed deletion unpublished when record and order rollback both fail', async () => {
|
||||
const dir = await makeDir('delete-double-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { deleteAt: 1, globalAt: 5 }),
|
||||
})
|
||||
const workspace = await result.registry.create(dir)
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(result.registry.get(workspace.id)).toBeUndefined()
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects table access before the registry has started', async () => {
|
||||
const dir = await makeDir('unstarted')
|
||||
const registry = new WorkspaceRegistry(new Context())
|
||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
||||
expect(() => registry.list()).toThrow(/not started/)
|
||||
const internals = registry as unknown as { requireTable(): unknown }
|
||||
expect(() => internals.requireTable()).toThrow(/not started/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -616,6 +744,49 @@ describe('header-validated membership projection', () => {
|
||||
internals.entities.delete(workspace.id)
|
||||
expect(() => result.registry.list()).toThrow(/references missing workspace/)
|
||||
})
|
||||
|
||||
it('recovers only an explicitly marked interrupted create or delete', async () => {
|
||||
const createDir = await makeDir('pending-create')
|
||||
const deleteDir = await makeDir('pending-delete')
|
||||
const createId = WorkspaceId('00000000-0000-4000-8000-000000000004')
|
||||
const deleteId = WorkspaceId('00000000-0000-4000-8000-000000000005')
|
||||
|
||||
const interruptedCreate = storedPool(
|
||||
[[createId, record(createDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'create', workspaceId: createId },
|
||||
},
|
||||
)
|
||||
const createRecovery = await harness({ pool: interruptedCreate })
|
||||
expect(createRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
|
||||
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const interruptedDelete = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
const deleteRecovery = await harness({ pool: interruptedDelete })
|
||||
expect(deleteRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
|
||||
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const corruptPending = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [deleteId],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
await expect(harness({ pool: corruptPending })).rejects.toThrow(/still present in registry order/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace mutation and status', () => {
|
||||
|
||||
Reference in New Issue
Block a user