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

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
#	packages/client/ui-conversation/tests/chat-apply.spec.tsx
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/README.md
#	packages/client/ui-primitives/README.zh.md
#	packages/client/ui-primitives/src/index.ts
This commit is contained in:
Chinesezjc
2026-07-31 15:55:40 +08:00
281 changed files with 7608 additions and 1014 deletions

View File

@@ -373,6 +373,13 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
// the presenter reads to emit the two-hunk sample: the card draws one path
// header, the first hunk, a `⋯` gap, then the second (the same-file
// second-hunk arm turns 62/63 cannot reach).
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
@@ -496,9 +503,26 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'edit':
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
// scattered hunks share one path header and the card draws the `⋯` gap.
if (str(args.file_path) === 'src/config.ts') {
return {
card: 'diff', title: `Edit ${str(args.file_path)}`,
diffs: [
{ path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
{ path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
],
}
}
return {
card: 'diff', title: `Edit ${str(args.file_path)}`,
diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
}
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
return {
card: 'diff', title: `Write ${str(args.file_path)}`,
diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
}
// A search call stays a generic card (kind: 'search'): the structured
// matches/paths exist only after execute, so the search card is result-time
// only (presentResult builds it). This mirrors the real grep/glob presenters.
@@ -1047,6 +1071,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
// Registry-global archive set mirroring the host: archived sessions keep
// their workspace accounting slot and only grouping surfaces hide them.
const archivedSessionIds: SessionId[] = []
// In-memory browse tree behind the fixture's `browse` picker capability —
// deterministic content mirroring the design mock so assembled Web tests
@@ -1701,7 +1728,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
openPath: request => ok(request, { opened: true as const }),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
list: request => ok(request, {
items: workspaces.map(w => ({ ...w })),
archivedSessionIds: [...archivedSessionIds],
}),
create: (request) => {
const { path, name } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
@@ -1787,6 +1817,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
return ok(request, { workspace: { ...workspace } })
},
archiveSession: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const { sessionId } = request.payload
if (!archivedSessionIds.includes(sessionId)) {
archivedSessionIds.push(sessionId)
emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] })
}
return ok(request, { archivedSessionIds: [...archivedSessionIds] })
},
},
commands: {
// The catalog mirrors one session's effective view (every fixture
@@ -2167,6 +2207,7 @@ export class FixtureApiClient extends AbstractApiClient {
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 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
case 'command.list': return this.api.commands.list(request)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)

View File

@@ -54,6 +54,7 @@ const PRIVILEGED_METHODS = new Set([
'settings.describe',
'settings.update',
'settings.replace',
'settings.mutate',
'credentials.describe',
'credentials.set',
'credentials.unset',

View File

@@ -122,7 +122,7 @@ export class FakeApiClient implements IApiClient {
}
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))),
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
created: true,
@@ -134,6 +134,9 @@ export class FakeApiClient implements IApiClient {
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' },
}))),
archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({
archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId],
}))),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real

View File

@@ -107,7 +107,7 @@ describe('connection node half', () => {
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'settings.replace',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
@@ -191,7 +191,7 @@ describe('connection node half over a real HTTP server', () => {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {

View File

@@ -21,7 +21,7 @@ function emptySessions() {
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54
README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b
README.md: 022dc6f82ea7aa1490144449ea61a84a512906a2
README.zh.md: 4d0f74f573a5e03b05755cfcfea930e69ef386e2

View File

@@ -10,6 +10,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
`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.
`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.
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.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.

View File

@@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set。它是全快照状态`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。

View File

@@ -76,4 +76,11 @@ export interface IWorkspaces {
* @returns the updated Workspace view.
*/
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
/**
* Archive a session into the registry-global set (hidden from grouping
* surfaces; session log and accounting slot remain). Archiving the current
* session clears the selection into the New Session view state.
* @param sessionId - session to archive.
*/
archiveSession(sessionId: SessionId): Promise<void>
}

View File

@@ -14,6 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable workspace-list snapshot. */
export interface WorkspaceListSnapshot {
items: readonly WorkspaceView[]
/**
* Registry-global archive set in Host order (hidden from grouping
* surfaces; accounting slots retained). A plain array, not a Set: public
* snapshot state stays in the store engine's plain-data vocabulary
* (immer drafts reject Sets without the MapSet plugin); membership
* lookups build their own transient Set where they need one.
*/
archivedSessionIds: readonly SessionId[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
@@ -28,11 +36,21 @@ export class WorkspaceManager {
private items: Workspace[] = []
private itemViewsSource: readonly Workspace[] | null = null
private itemViewsCache: readonly WorkspaceView[] = []
// Full-snapshot state (list response / unary response / changed frame all
// carry the complete set), so deltas never merge — installs replace.
private archivedSessionIds: readonly SessionId[] = []
private state: WorkspaceListSnapshot['state'] = 'idle'
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceDelta[] | null = null
/**
* True once a frame or unary echo installed the archive set while a list
* request was in flight: that install is newer than the pending baseline,
* so the baseline's (older) set must not roll it back — the archive
* mirror of replaying refreshFrames over the item baseline.
*/
private archivedSupersedesRefresh = false
/**
* 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
@@ -77,6 +95,7 @@ export class WorkspaceManager {
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds)
this.state = 'idle'
this.phase = 'ready'
} else {
@@ -90,6 +109,7 @@ export class WorkspaceManager {
this.error = folded.ok ? null : folded.error
} finally {
this.refreshFrames = null
this.archivedSupersedesRefresh = false
this.inflight = null
this.notifier.markDirty()
}
@@ -158,6 +178,18 @@ export class WorkspaceManager {
return result
}
/**
* Archive one session in the registry-global set, then install the
* returned full set without waiting for the changed frame.
* @param sessionId - session to archive.
* @returns the wire result.
*/
async archiveSession(sessionId: SessionId): Promise<RpcResult<{ archivedSessionIds: SessionId[] }>> {
const { result } = await this.api.workspace.archiveSession({ sessionId })
if (result.ok) this.installArchived(result.value.archivedSessionIds)
return result
}
/**
* Host-frame entry. Non-workspace frames are ignored so the runtime can
* fan one host stream out to both object managers.
@@ -166,6 +198,9 @@ export class WorkspaceManager {
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
else if (envelope.payload.type === 'host/archived-sessions-changed') {
this.installArchived(envelope.payload.archivedSessionIds)
}
}
/** Re-pull the baseline after each connection generation. */
@@ -194,12 +229,26 @@ export class WorkspaceManager {
private buildSnapshot(): WorkspaceListSnapshot {
return {
items: this.itemViews(),
archivedSessionIds: this.archivedSessionIds,
state: this.state,
phase: this.phase,
error: this.error,
}
}
/**
* Replace the archive set when membership actually changed (array identity
* backs Object.is short-circuits). Host snapshots are append-ordered, so
* positional comparison is exact, not merely heuristic.
*/
private installArchived(archivedSessionIds: readonly SessionId[]): void {
if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true
if (archivedSessionIds.length === this.archivedSessionIds.length
&& archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return
this.archivedSessionIds = [...archivedSessionIds]
this.notifier.markDirty()
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
if (this.removedIds.has(view.workspaceId)) return

View File

@@ -14,6 +14,14 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
export interface WorkspaceListState {
items: readonly WorkspaceView[]
/**
* Registry-global archive set in Host order: grouping surfaces hide these
* sessions everywhere (workspace groups and the ungrouped bucket) while
* their session logs and workspace accounting slots remain. A plain array
* (store-engine vocabulary; immer drafts reject Sets) — membership lookups
* build their own transient Set.
*/
archivedSessionIds: readonly SessionId[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
@@ -58,7 +66,7 @@ export class WorkspacesService implements IWorkspaces {
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null,
baselinesReady: false, recentWorkspaceId: undefined,
})
this.manager.subscribe(() => { this.project() })
@@ -88,10 +96,14 @@ export class WorkspacesService implements IWorkspaces {
if (inflight !== undefined) return inflight
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
// canon; summary cwd is the session header passthrough of the same canon).
// An archived blank is never reused: reuse would open a session no
// grouping surface can show, so New Session mints a fresh one instead.
const archived = this.list.getSnapshot().archivedSessionIds
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
@@ -249,6 +261,17 @@ export class WorkspacesService implements IWorkspaces {
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Archive a session into the registry-global set. Clearing an archived
* current selection is the projection sweep's job (one rule for the local
* echo and a remote tab's frame alike).
* @param sessionId - session to archive.
*/
async archiveSession(sessionId: SessionId): Promise<void> {
const result = await this.manager.archiveSession(sessionId)
if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
@@ -291,8 +314,17 @@ export class WorkspacesService implements IWorkspaces {
const workspace = this.manager.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
// An archived current selection clears into the New Session view state —
// a hidden row must not stay open behind the list. Sweeping here covers
// every install path with one rule: the local unary echo, another tab's
// changed frame, and a reconnect baseline restoring a persisted
// selection that was archived while this client was away.
if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) {
this.sessions.clear()
}
this.list.set({
items: workspace.items,
archivedSessionIds: workspace.archivedSessionIds,
state: workspace.state,
phase: workspace.phase,
error: workspace.error,

View File

@@ -140,7 +140,10 @@ export class FakeApiClient implements IApiClient {
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
// The archive-set field defaults at the binding below so list stubs keep
// the pre-archive `{ items }` shape; a stub carrying the field wins.
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[]; archivedSessionIds?: never[] }>> =
() => Promise.resolve(ok({ items: [] }))
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
@@ -153,13 +156,22 @@ export class FakeApiClient implements IApiClient {
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceArchiveSession: (payload: unknown) => Promise<RpcResponse<{ archivedSessionIds: SessionId[] }>> =
payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => (
response.result.ok
? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } }
: response
)) as ReturnType<IApiClient['workspace']['list']>),
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)),
archiveSession: (payload: unknown) =>
this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real

View File

@@ -183,6 +183,12 @@ describe('WorkspacesService', () => {
// Unknown workspace fails loud instead of silently creating in nowhere.
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
// An archived blank is never reused: no surface can show it, so New
// Session mints a fresh one for alpha instead.
await workspaces.archiveSession(sid('s-blank'))
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
})
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
@@ -285,6 +291,84 @@ describe('WorkspacesService', () => {
}))
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({
items: [
{ sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
{ sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
],
}) as never)
await sessions.refresh()
sessions.open(sid('s-open'))
// Archiving a non-current session installs the unary echo and keeps the selection.
await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
expect(sessions.list.getSnapshot().current).toBe('s-open')
// Archiving the current session clears it into the New Session view state.
api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
await workspaces.archiveSession(sid('s-open'))
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
expect(sessions.list.getSnapshot().current).toBeUndefined()
// A Host failure leaves the set and the selection untouched.
api.onWorkspaceArchiveSession = () => Promise.resolve(err({
code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
}))
await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
// The changed frame and the list baseline both re-install the full set.
workspaces.handleHostEnvelope({
rpcId: 'frame' as never,
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] },
} as never)
// Frame installs ride the notifier's microtask batch before projecting.
await new Promise(resolve => setTimeout(resolve, 0))
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never)
await workspaces.refresh()
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
})
it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
}) as never)
await sessions.refresh()
sessions.open(sid('s-open'))
// A stale baseline is in flight (older, empty set) when another tab's
// archive frame lands: the frame clears the current selection and its
// set survives the baseline's later resolution.
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const hydration = workspaces.refresh()
workspaces.handleHostEnvelope({
rpcId: 'frame' as never,
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
} as never)
await new Promise(resolve => setTimeout(resolve, 0))
expect(sessions.list.getSnapshot().current).toBeUndefined()
gate.resolve(ok({ items: [], archivedSessionIds: [] }))
await hydration
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
// The next (fresh) baseline is authoritative again.
api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
await workspaces.refresh()
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
})
})
describe('startInitialSelection', () => {

View File

@@ -73,6 +73,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
export function workspaceListState(): WorkspaceListState {
return {
items: [],
archivedSessionIds: [],
state: 'idle',
phase: 'ready',
error: null,

View File

@@ -186,4 +186,21 @@ export class TestWorkspaces implements IWorkspaces {
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
}
/**
* Archive a session (recorded). The default mirrors the production face's
* observable effect: the id joins the list state's archive set.
* @param sessionId - session to archive.
*/
async archiveSession(sessionId: SessionId): Promise<void> {
this.calls.push({ method: 'archiveSession', args: [sessionId] })
const stub = this.stubs.get('archiveSession')
if (stub !== undefined) {
await (stub(sessionId) as Promise<void>)
return
}
await this.update((draft) => {
draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId]
})
}
}

View File

@@ -551,8 +551,12 @@ describe('workspaces action face', () => {
await ws.openPath('/proj/file.ts')
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
expect(moved.sessionIds).toEqual(['s1'])
// Default archive mirrors the production effect: the id joins the list
// state's archive set (features render against the same snapshot).
await ws.archiveSession('s1' as SessionId)
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
expect(ws.calls.map(c => c.method)).toEqual(
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore'])
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
@@ -560,12 +564,16 @@ describe('workspaces action face', () => {
ws.stub('delete', () => Promise.resolve())
ws.stub('openPath', () => Promise.resolve())
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
ws.stub('archiveSession', () => Promise.resolve())
expect((await ws.create({ name: 'y' })).title).toBe('X')
await expect(ws.pickDirectory()).resolves.toBe('/picked')
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/other')
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
// The stub replaces the default set mutation: the set stays as-is.
await ws.archiveSession('s2' as SessionId)
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
await runtime.dispose()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 76153ee22f49b5644665fc6778ce2ef345fc54be
README.zh.md: 15fbb1dfb2dd99af635b71ce2dfa19899ce33a25
README.md: dce5fe10c7e8bc01588a6cf87fc15cac815154a9
README.zh.md: 2c5581194d8d67d88b823b3226844ea64eb64c44

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
@@ -18,6 +18,8 @@ A tool call declaring the `terminal` render intent renders its command output in
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
@@ -16,6 +16,8 @@
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值wire 上不可信其为 `search``fetch`),它返回 null落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search``web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`8面板为 16与终端卡片所画的摘要面对阅读面的同一划分[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
声明 `diff` 渲染意图的工具调用(`write``edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView``resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff对任何其他 card 标签或 generic result viewwrite/edit 的执行错误)它返回 null落回通用路径。键控的 `FileMutationRow`(在 `write``edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`8面板为 16[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试Host 的 running 位只控制实时动画随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line`glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind它都返回 null落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。

View File

@@ -21,6 +21,7 @@ import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { searchToolview } from './toolviews/search-row.tsx'
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
import { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
@@ -324,6 +325,10 @@ export function apply(ctx: Context): void {
// under both tool names, since both declare the same search render intent.
ctx.plugin(searchToolview)
// The write/edit rows ride the same seam: a file-mutation call declares the
// diff render intent, so these rows stack the applied diff card under their
// path-link summary (the terminal card's posture, applied to diffs).
ctx.plugin(fileMutationToolview)
// The web rows ride the same seam: one WebRow registered under both
// web_search and web_fetch, rendering the completed retrieval's web card
// resident under the summary (a product registration, not a sample).

View File

@@ -66,33 +66,45 @@
border-left: 1px solid var(--dsw-alias-border-l2);
}
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
right with a stepped trail — flat keyframe holds, no tweening. Phase
offsets come from per-rect animation-delay (index * -250ms) set inline
by the component. */
.turnDots {
/* Turn activity keeps the former loader's one-line footprint. A pale
brand-blue band sweeps from left to right; reduced-motion keeps it static. */
.turnStatus {
align-self: flex-start;
flex: none;
display: flex;
display: inline-flex;
align-items: center;
/* One message line box: the dots center inside the text line height. */
height: 26px;
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
is the 500 step, not this 450). */
color: var(--dsw-static-deepseek-450);
font: var(--dsw-font-s-strong-14);
white-space: nowrap;
background: linear-gradient(
90deg,
var(--dsw-static-deepseek-500) 0%,
var(--dsw-static-deepseek-500) 40%,
var(--dsw-static-deepseek-200) 50%,
var(--dsw-static-deepseek-500) 60%,
var(--dsw-static-deepseek-500) 100%
);
background-position: 100% 0;
background-size: 250% 100%;
background-clip: text;
color: transparent;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: dsh-turn-status-shimmer 1.8s linear infinite;
}
.turnDotCell {
fill: currentColor;
opacity: 0.15;
animation: dsh-turn-dots-chase 1s infinite;
@keyframes dsh-turn-status-shimmer {
to {
background-position: 0 0;
}
}
@keyframes dsh-turn-dots-chase {
0%, 24.9% { opacity: 1; }
25%, 49.9% { opacity: 0.6; }
50%, 74.9% { opacity: 0.35; }
75%, 100% { opacity: 0.15; }
@media (prefers-reduced-motion: reduce) {
.turnStatus {
background-position: 0 0;
background-size: 100% 100%;
animation: none;
}
}
.hint {

View File

@@ -204,36 +204,11 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
)
})
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
* per-rect animation-delay. */
const LOADER_CELLS = [0, 5, 10, 15] as const
function TurnDots() {
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus() {
return (
/* The wrapper is a 26px line box (message line height) so the loader
occupies one text line and centers the dots inside it. */
<div className={css.turnDots} aria-hidden="true">
<svg
width="17.5"
height="2.5"
viewBox="0 0 17.5 2.5"
shapeRendering="crispEdges"
>
{LOADER_CELLS.map((x, index) => (
<rect
key={x}
className={css.turnDotCell}
x={x}
y="0"
width="2.5"
height="2.5"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
/>
))}
</svg>
<div className={css.turnStatus} role="status" aria-live="polite">
Deep diving...
</div>
)
}
@@ -491,7 +466,7 @@ export function ChatView({
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
{running && <TurnStatus />}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>

View File

@@ -11,6 +11,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { searchCardModel } from '../contract/search-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -38,6 +39,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const search = searchCardModel(block)
const diff = diffCardModel(block)
const web = webCardModel(block)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
@@ -56,11 +58,15 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
// it outranks the args-derived summary here exactly as it does in BashRow;
// a search result view's replacement title outranks it the same way.
summary={terminal?.description ?? search?.title ?? model.summary}
body={model.body}
// Single-file tools never expose an args body — the path link is the only
// args interaction. A diff card is not an args body: a write/edit row is
// single-file AND carries a diff, so the card expands under the path link.
body={singleFile ? null : model.body}
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
search={search}
diff={diff}
state={state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}

View File

@@ -269,6 +269,12 @@
color: var(--dsw-alias-label-tertiary);
}
/* A write/edit diff renders through DiffBlock; like the terminal card it draws
its own surface, so only the row indentation is this file's concern. */
.diffBody {
margin: 4px 0 4px 4px;
}
/* In-row code renders at the smaller code size (12/18) via each primitive's
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
.codeBody {

View File

@@ -1,7 +1,5 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary, drawn through the shared
// DisclosureRow chrome with the whole row as the expand toggle (click /
// Enter / Space, icon→chevron hover preview). The collapsed row is always
@@ -21,9 +19,10 @@
import { useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
@@ -56,9 +55,16 @@ export interface ToolRowProps {
* Search-card material for a call whose render intent is a search card
* (derived by `searchCardModel`); it replaces the text body when present.
* Null or absent leaves the text body. A call carries at most one card kind,
* so `terminal` and `search` are never both present on the same row.
* so `terminal`, `search`, and `diff` are never both present on the same row.
*/
search?: SearchCardModel | null | undefined
/**
* Diff-card material for a call whose render intent is a diff card (derived by
* `diffCardModel`); it replaces the text body when present, the same way
* `terminal` does. A call carries at most one card intent, so the cards are
* never both set.
*/
diff?: DiffCardModel | null | undefined
state: ToolRowState
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
@@ -107,6 +113,7 @@ export function ToolRow({
errorSummary,
terminal,
search,
diff,
state,
filePath,
onOpenFile,
@@ -115,10 +122,11 @@ export function ToolRow({
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
const searchBody = search ?? null
const diffBody = diff ?? null
const outputText = output ?? null
// A search card replaces the text body; a call carries at most one card kind,
// so terminal and search are never both present on a row.
const expandable = body !== null || outputText !== null || terminalBody !== null || searchBody !== null
// A search or diff card replaces the text body; a call carries at most one
// card kind, so terminal, search, and diff are never both present on a row.
const expandable = body !== null || outputText !== null || terminalBody !== null || searchBody !== null || diffBody !== null
const open = expanded && expandable
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
@@ -201,38 +209,40 @@ export function ToolRow({
)}
</>
)
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
: diffBody !== null
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"

View File

@@ -0,0 +1,100 @@
/**
* Pure derivation of the diff-card props from a frozen call slice: the
* `card:'diff'` render intent the write/edit tools declare arrives on the
* snapshot as `callView`/`resultView`, and this is the one place that turns
* that pair into what {@link DiffBlock} draws. Both conversation render sites
* (the chat tool row's expanded body and the details panel's Output section)
* call this, so the hunks they show are derived once.
* @module
*/
import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
/**
* Diff-body lines the chat row shows before collapsing the middle — half the
* primitive's own default, which the details panel keeps. A chat row is a
* summary surface inside the message flow: the flow must stay scannable across
* many calls, while the details panel is the single-call reading surface. The
* same split {@link CHAT_TERMINAL_MAX_LINES} draws for a terminal card, so the
* two card kinds cap a long body at the same place in the flow. A design
* constant of this UI's row geometry, not a deployment choice.
*/
export const CHAT_DIFF_MAX_LINES = 8
/**
* The {@link DiffBlock} props this derivation owns. Picked off the primitive's
* props so the two stay in step; `maxLines`/`className` belong to each render
* site.
*/
export interface DiffCardModel {
/**
* The props {@link DiffBlock} draws. Held as a nested object so a render site
* spreads exactly the primitive's own surface and can never leak a
* neighbouring field into it.
*/
card: Pick<DiffBlockProps, 'diffs'>
}
/**
* Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event
* view crosses the wire and `toolEventViewSchema` validates only the `card`
* string, so a version mismatch or an anomalous plugin can deliver a `diff` card
* whose `diffs` is absent, not an array, or carries malformed hunks. Returning
* null for any of those routes the block to the generic path instead of letting
* DiffBlock's `for...of`/`split` throw and crash the row or the details panel.
* @param diffs - the view's `diffs` field, unverified.
* @returns the validated hunks, or null when the payload is not usable.
*/
function narrowDiffs(diffs: unknown): DiffHunk[] | null {
if (!Array.isArray(diffs) || diffs.length === 0) return null
const out: DiffHunk[] = []
for (const hunk of diffs) {
if (typeof hunk !== 'object' || hunk === null) return null
const { path, oldText, newText } = hunk as Record<string, unknown>
if (typeof path !== 'string') return null
if (oldText !== null && typeof oldText !== 'string') return null
if (typeof newText !== 'string') return null
out.push({ path, oldText, newText })
}
return out
}
/**
* Derive the diff-card props for a tool call, or null when this call is not a
* diff card and belongs on the generic path.
*
* The result side is authoritative once the call settles: the write/edit tools
* return the applied contextual hunks there (an edit's real before/after, a
* create's whole-file diff), which replace the call-time diff derived from the
* arguments alone. While the call is still running only the call side exists,
* so a running write/edit shows its intended change. Null is the documented
* generic-card default and covers every non-diff card — including a `card`
* value this UI version does not know, which arrives over the wire and cannot
* be trusted to be one of the compiled variants — and a settled call whose
* result view is generic (how write/edit keep their execution errors on the
* generic path).
*
* This derivation consumes only `diffs`; the render intent's `title` field is
* deliberately dropped. The row supplies its own title (`Edit`/`Write · path`
* from the args) and that outranks the view's `title`, matching the TUI diff
* branch, which likewise draws no view title. A tool that names its own diff
* header therefore does not surface that text on the Web row — an accepted
* product choice, recorded here as the one asymmetry with the terminal card,
* whose derivation does consume the view's title.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @returns the diff-card props, or null for the generic path.
*/
export function diffCardModel(block: ToolCallBlock): DiffCardModel | null {
if (!('kind' in block)) {
// Running: the call view may carry the intended diff; the result is absent.
const call = block.callView?.card === 'diff' ? block.callView : null
const diffs = call === null ? null : narrowDiffs(call.diffs)
return diffs === null ? null : { card: { diffs } }
}
// Settled: the result view's applied hunks replace the call-time diff. A
// window that dropped the call head leaves only the result, which still
// renders — the result view carries the whole change.
const result = block.resultView?.card === 'diff' ? block.resultView : null
const diffs = result === null ? null : narrowDiffs(result.diffs)
return diffs === null ? null : { card: { diffs } }
}

View File

@@ -107,6 +107,10 @@
border-radius: 8px;
}
.row + .row {
box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
}
.preview,
.editor {
flex: 1 1 auto;

View File

@@ -101,10 +101,10 @@
font: var(--dsw-font-xs-13);
}
/* A render-intent card (terminal or search) sits directly under its section
/* A card body (terminal, search, or diff) sits directly under its section
label, so it drops the primitive's standalone vertical margin; the section
owns the spacing. */
.terminal {
owns the spacing. Card-neutral: no card-specific value. */
.cardBody {
margin: 0;
}

View File

@@ -7,11 +7,12 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { searchCardModel } from '../contract/search-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
@@ -132,9 +133,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* its alignment and scrolls sideways instead of folding. A search-card call —
* a `grep`/`glob` result view — renders through the shared SearchBlock at the
* same full height allowance, with a capped search's recovery footer below it.
* A web-card call — a `web_search`/`web_fetch` result — renders through WebBlock
* at its own full source-list allowance. Every other call, and a running call
* with no card yet, keeps the flattened text form.
* A diff-card call — a write/edit's applied change — renders through the shared
* DiffBlock at the same full height. A web-card call — a `web_search`/`web_fetch`
* result — renders through WebBlock at its own full source-list allowance. Every
* other call, and a running call with no card yet, keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @param props.t - the panel's locale seat, passed down as a plain prop.
@@ -150,7 +152,7 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
{terminal.description !== undefined && (
<div className={css.terminalDescription}>{terminal.description}</div>
)}
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.terminal} />
<TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.cardBody} />
</>
)
}
@@ -158,7 +160,7 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
if (search !== null) {
return (
<>
<SearchBlock {...search.card} className={css.terminal} />
<SearchBlock {...search.card} className={css.cardBody} />
{/* A capped search's recovery locator lives only in the result text;
show it below the card so the dropped rows stay reachable. */}
{search.recovery !== undefined && (
@@ -167,6 +169,8 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const diff = diffCardModel(material.block)
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
const web = webCardModel(material.block)
// Full source-list allowance here (the panel is the single-call reading
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the

View File

@@ -193,6 +193,18 @@
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
/* These three MUST wrap at one width, because InputBar mirrors a single
scroll offset between .input and .backdrop and a layer that wraps onto
more lines is taller, has a larger scroll maximum, and clamps the mirrored
offset below the caret. Only .input scrolls, so only .input can lose
content width to a scrollbar that consumes layout space.
`scrollbar-gutter: stable` here does NOT buy that guarantee and was
removed after measuring: WebKit applies it to overflow-y:auto but not to
the overflow:hidden layers, so it left .input at 768 against 776 — the
same gap it was meant to close — while costing chromium 8px of text width
unconditionally. The gap it would have closed is measured and recorded in
the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
closing it needs one geometry every engine agrees on, not this property. */
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */

View File

@@ -62,6 +62,7 @@ export function InputBar({
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const backdropRef = useRef<HTMLDivElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -91,11 +92,22 @@ export function InputBar({
if (!locked) inputRef.current?.focus()
}, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
// the native scroll; only at its own edge forward delta to the host so a
// short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
// Two DOM listeners on the textarea, one lifetime (it is never unmounted —
// the inert state renders the same element disabled).
//
// wheel — active conversation scrollport: chain the gesture. While the
// textarea (capped at 14 lines with overflow-y:auto) can still move in this
// direction, keep the native scroll; only at its own edge forward delta to
// the host so a short draft never traps the gesture and a long draft stays
// scrollable. Hero mounts have no host and keep native wheel scrolling.
//
// scroll — the backdrop paints every visible glyph (the textarea's own text
// is transparent) but is clipped, not scrolled, so it does not follow the
// textarea on its own: without this mirror a draft past the cap moves the
// caret while the words stay frozen in place. Every way the box moves ends
// in a `scroll` event, edits included (the caret is scrolled into view), and
// the layers share an extent, so a draft that shrinks past the offset clamps
// both to the same maximum — one listener covers the coupling.
useEffect(() => {
const el = inputRef.current
if (el === null) return
@@ -108,8 +120,16 @@ export function InputBar({
e.preventDefault()
host.scrollTop += e.deltaY
}
const onScroll = (): void => {
const backdropEl = backdropRef.current
if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('scroll', onScroll)
}
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
@@ -355,6 +375,22 @@ export function InputBar({
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
// Trailing-line sentinel, the same one the mirror div carries and for the
// same reason: a textarea reserves a line box for the caret after a final
// newline, while `white-space: pre-wrap` collapses a text node's trailing
// newline and generates none. Without it a draft ending in a newline makes
// the backdrop exactly one line SHORTER than the textarea, so mirroring the
// offset at the very bottom clamps and the glyphs sit a line behind the
// caret. The extra newline is absorbed by that same collapse when the draft
// does not end in one, so it costs no height in the ordinary case.
//
// The mirror only fails one way — a backdrop SHORTER than the textarea
// clamps the assignment, while a taller one takes every offset exactly and
// hides the surplus below the clip. That is why the ghost hint needs no
// handling of its own: it can only add content after the draft and before
// this sentinel, never remove a line box, so it moves the pair to equal or
// to the safe side.
backdrop.push('\n')
}
return (
@@ -376,7 +412,7 @@ export function InputBar({
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}

View File

@@ -0,0 +1,130 @@
/* File-mutation toolview: same geometry/tokens as ToolRow (figma
{Edit,Write} · path), plus the diff card the row stacks under its summary
line. Mirrors bash-sample.module.css, whose terminal card this replaces with
a diff card. */
/* Summary line over the diff card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.diff {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-file-mutation-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.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;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* The result text for an errored mutation, indented to the card's own column
(the diff card's inset) and in the error tone, since it stands in for the diff
card the failure path does not produce. */
.failure {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-state-error-primary);
}

View File

@@ -0,0 +1,123 @@
// File-mutation toolview registrant: third-party posture over the keyed
// toolview hole (ctx.slots.register + ToolRowProps only — never imports the
// chat domain), registered under both `edit` and `write`. Product chrome
// matches ToolRow (figma: {Edit,Write} · {path}).
//
// A write/edit call declares the diff render intent, so this row renders the
// applied change through DiffBlock resident below its summary line — the same
// posture BashRow gives a terminal card. The row has no expand control and is
// not a details-panel target (tool rows stopped being one), so the diff body
// is resident rather than expand-gated, and the card's own copy and expand
// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body
// against the message flow; the details panel keeps the block's full default.
// The summary stays a path link (the file-tool interaction) that opens through
// the host.
import type { Context } from 'cordis'
import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './file-mutation-row.module.css'
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return <IconEditOutline16 size={14} />
}
}
/** 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
}
}
/**
* A settled result's text, flattened from its content blocks, for the arm that
* shows a failure the diff card cannot: write/edit return `undefined` from
* `presentResult` on `result.isError`, so an errored mutation has no diff card,
* and the keyed row is not a details-panel target. Without this the failure —
* an `old_string` that did not match, a permission denial — would read as a bare
* red dot with the model-facing error text nowhere on screen.
* @param block - the frozen call slice.
* @returns the result text, or null for a running call or an empty result.
*/
function errorText(block: ToolRowProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
for (const item of block.content) {
if (item.type === 'text') parts.push(item.text)
}
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
const text = parts.join('\n')
return text === '' ? null : text
}
/**
* File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
* with the applied diff resident below it. The summary is a path link (a file
* tool's interaction); the host's `openFile` resolves it against the session
* cwd, so this passes the tool's own path verbatim. The card's copy and expand
* controls are the row's only other actions.
*/
export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) {
const model = toolRowModel(toolName, block, cwd)
const diff = diffCardModel(block)
const status = stateStatus(model.state)
const filePath = model.filePath
// An errored mutation has no diff card (presentResult returns undefined on
// isError); surface its result text so the failure is more than a red dot.
const failure = diff === null && model.state === 'error' ? errorText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant={model.variant} data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{filePath !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(filePath) }}
>
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{diff !== null && (
<DiffBlock {...diff.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diff} />
)}
{failure !== null && <div className={css.failure}>{failure}</div>}
</div>
)
}
/**
* The file-mutation rows as a plain registrant plugin. `inject` carries the
* load-order seam: requiring the conversation service guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export const fileMutationToolview = {
name: 'file-mutation-toolview',
inject: ['slots', 'conversation'],
/**
* Register the file-mutation row into the chat view's keyed toolview hole
* under both mutation tool names.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow)
},
}

View File

@@ -84,14 +84,15 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the search rows, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// one search row registers under both grep and glob; the web rows register
// one component under both web tool names.
// one search row registers under both grep and glob; the file-mutation
// registrant claims both write and edit for the diff card; the web rows
// register one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

View File

@@ -128,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) {
ctx.provide('sessions', sessionsFake)
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),

View File

@@ -5,7 +5,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
@@ -94,7 +94,7 @@ function emptySessions() {
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
@@ -254,16 +254,16 @@ describe('ChatView', () => {
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<h.ChatView {...h.props} />)
const disclosure = view.container.querySelector('details')
expect(disclosure?.dataset.active).toBe('true')
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
const disclosure = view.container.querySelector('details') as HTMLDetailsElement
expect(disclosure.dataset.active).toBe('true')
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
})
expect(view.getAllByRole('status')).toHaveLength(1)
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
expect(view.container.querySelector('details')).toBe(disclosure)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求2/2 · 1s')
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求2/2 · 1s')
act(() => {
h.set({
@@ -277,14 +277,15 @@ describe('ChatView', () => {
running: false,
})
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toBe('已重试模型请求2/2 · 1s')
expect(disclosure.dataset.active).toBeUndefined()
expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求2/2 · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toContain('重试已取消')
const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement
expect(cancelledDisclosure.dataset.active).toBeUndefined()
expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
@@ -448,6 +449,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(view.getByText('cmd-r1')).toBeTruthy()
expect(view.getByRole('status').textContent).toBe('Deep diving...')
})
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {

View File

@@ -0,0 +1,344 @@
// @vitest-environment jsdom
// The diff render intent on the web side: the pure diffCardModel derivation
// over callView/resultView, and both conversation render sites that consume it
// — the chat tool row's expanded body (GenericToolCard / FileMutationRow) and
// the details panel's Output section.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
const t = makeTranslate(zh, commonZh)
const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}'
/** The edit tool's own call view (a call-time diff derived from the arguments). */
const callDiff = (over?: Partial<Extract<ToolCallView, { card: 'diff' }>>): ToolCallView => ({
card: 'diff', title: 'Edit notes/demo.txt',
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
})
/** The edit tool's own result view (the applied hunk diff). */
const resultDiff = (over?: Partial<Extract<ToolResultView, { card: 'diff' }>>): ToolResultView => ({
card: 'diff', title: 'Edit notes/demo.txt',
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'edit', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
callView: callDiff(), resultView: resultDiff(), ...over,
})
describe('diffCardModel', () => {
it('derives a running card from the call view alone', () => {
expect(diffCardModel(running())).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
})
})
it('derives a settled card from the result view, which replaces the call-time diff', () => {
// The applied hunks (result) win over the args-derived call diff.
expect(diffCardModel(settled({
resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }),
}))).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] },
})
})
it('renders a settled diff even when the window dropped the call head', () => {
// A truncated call carries only the result view, which holds the whole change.
expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1)
})
it('returns null for every non-diff call: no views, generic views, unknown cards', () => {
expect(diffCardModel(running({ callView: null }))).toBeNull()
expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull()
expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
// A generic result settles a diff call on the generic path (write/edit's
// own execution-error arm).
expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
expect(diffCardModel(running({ callView: future }))).toBeNull()
expect(diffCardModel(settled({
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
}))).toBeNull()
})
it('falls back to null for a malformed diff payload off the wire', () => {
// toolEventViewSchema validates only the `card` string, so a version
// mismatch can deliver a diff card with an unusable diffs field. Each shape
// routes to the generic path instead of throwing inside DiffBlock.
const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView)
expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull()
expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull()
// The running side narrows identically.
expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull()
})
})
describe('chat row diff body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t,
})
it('the expanded body is the applied diff, capped tighter than the panel', () => {
expect(CHAT_DIFF_MAX_LINES).toBeLessThan(16)
const view = render(<GenericToolCard {...ownerProps(settled())} />)
// Collapsed: the summary row (path) only, no diff body.
expect(view.queryByText('hello fixture')).toBeNull()
// The path link is not the expand control; the leading toggle is.
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
})
it('a running diff call expands to its intended change', () => {
const view = render(<GenericToolCard {...ownerProps(running())} />)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
})
it('a non-diff call keeps the args-JSON text body', () => {
// A non-file tool name so the row is not single-file (no path link), and its
// args body is the fallback the diff card must not have replaced.
const view = render(<GenericToolCard {...{
callId: 'c1', toolName: 'some_tool', openFile: vi.fn(), t,
block: settled({
call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
callView: null, resultView: null,
}),
}} />)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.getByText(/"foo"/)).toBeTruthy()
})
})
describe('FileMutationRow diff card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
it('renders the applied diff under the summary row, without an expand gesture', () => {
const view = render(<FileMutationRow {...rowProps(settled())} />)
// The diff card is resident (no expand toggle needed).
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
expect(view.getByText('复制')).toBeTruthy()
})
it('the summary is a path link that opens the tool path through the host', () => {
const openFile = vi.fn()
const view = render(<FileMutationRow {...{ ...rowProps(settled()), openFile }} />)
fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' }))
// The row passes the tool's own path; the injected openFile resolves it
// against the session cwd (apply.ts), so the row must not resolve twice.
expect(openFile).toHaveBeenCalledWith('notes/demo.txt')
})
it('registers under write too, rendering a create as an added-only diff', () => {
const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}'
const view = render(<FileMutationRow {...rowProps(settled({
call: { name: 'write', argsRaw: writeArgs },
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
}), 'write')} />)
expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('reflects the run state on its leading slot', () => {
const runningView = render(<FileMutationRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
cleanup()
const errorView = render(<FileMutationRow {...rowProps(settled({ isError: true, resultView: null, callView: null }))} />)
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('a mutation call with no diff view renders the summary row alone', () => {
const view = render(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
expect(view.container.querySelector('[data-diff]')).toBeNull()
})
it('surfaces the result text when an errored mutation has no diff card', () => {
// write/edit return undefined from presentResult on isError, so the failure
// has no diff — the row shows the model-facing error text instead of a bare
// red dot.
const view = render(<FileMutationRow {...rowProps(settled({
isError: true, callView: null, resultView: null,
content: [{ type: 'text', text: 'old_string not found in notes/demo.txt' }],
}))} />)
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.getByText('old_string not found in notes/demo.txt')).toBeTruthy()
})
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render(<FileMutationRow {...rowProps(settled({
isError: true, callView: null, resultView: null, content: [],
error: { name: 'ToolError', code: 'sandbox_denied' },
}))} />)
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
})
it('shows no failure text for a successful diff or a running call', () => {
const ok = render(<FileMutationRow {...rowProps(settled())} />)
expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull()
cleanup()
const run = render(<FileMutationRow {...rowProps(running())} />)
expect(run.container.querySelector('[class*="_failure_"]')).toBeNull()
})
it('shows the stopped state when the call was interrupted', () => {
const view = render(<FileMutationRow {...rowProps(settled({
callView: null, resultView: null, isError: true,
error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
// The visually-hidden status label carries the stopped semantic for AT.
expect(view.getByText('已停止')).toBeTruthy()
})
it('renders a plain summary span when the call carries no file path', () => {
// Empty args leave deriveFilePath undefined, so the summary is not a link.
const view = render(<FileMutationRow {...rowProps(settled({
call: { name: 'edit', argsRaw: '' }, callView: null, resultView: null,
}))} />)
expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull()
expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull()
})
})
describe('fileMutationToolview registration', () => {
it('registers one component under both edit and write, and each disposes', () => {
const registered: { key: string; disposed: boolean }[] = []
const disposers: (() => void)[] = []
const ctx = {
slots: {
register: ({ key }: { name: string; key: string }) => {
const entry = { key, disposed: false }
registered.push(entry)
const dispose = () => { entry.disposed = true }
disposers.push(dispose)
return dispose
},
},
}
fileMutationToolview.apply(ctx as never)
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
// The registrant's inject seam is the load-order contract the row relies on.
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
// Disposal removes each contribution (packages/AGENTS.md registry contract).
for (const dispose of disposers) dispose()
expect(registered.every(r => r.disposed)).toBe(true)
})
})
describe('DetailsPanel diff Output section', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' }
it('renders the applied diff at full height, keeping the JSON Input section', () => {
const view = mount(snapshot({ nodes: [settled()] }), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
})
it('a running diff call renders its intended change, not the 运行中… placeholder', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.queryByText('运行中…')).toBeNull()
})
it('a non-diff result keeps the flattened pre', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'text', text: 'permission denied' }],
})],
}), target)
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied')
})
})

View File

@@ -74,7 +74,7 @@ describe('render branch tails', () => {
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(
@@ -111,7 +111,7 @@ describe('render branch tails', () => {
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(

View File

@@ -96,7 +96,7 @@ function bench(over?: BenchOptions) {
ids: [], byId: {}, current: undefined, phase: 'ready',
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
@@ -289,6 +289,37 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
textarea.scrollTop = 120
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(120)
// Every later move tracks too, including back to the top — a one-shot
// mirror would leave the glyphs parked at the first offset it saw.
textarea.scrollTop = 0
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(0)
})
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
// (the browser scenario owns that); what is checkable is that the backdrop's
// text is the draft plus exactly one newline. A textarea reserves a line box
// after a final newline and `pre-wrap` collapses one, so without the
// sentinel a draft ending in a newline leaves the backdrop a line short and
// the mirrored offset clamps.
const withNewline = bench({ draft: 'alpha\nbeta\n' })
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
const withoutNewline = bench({ draft: 'alpha\nbeta' })
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(plain.textContent).toBe('alpha\nbeta\n')
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('会话不可用')

View File

@@ -39,7 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
ids: [], byId: {}, current: undefined, phase: 'ready',
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: (() => undefined),

View File

@@ -125,7 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
ids: [], byId: {}, current: undefined, phase: 'ready',
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: (() => undefined),

View File

@@ -344,7 +344,7 @@ describe('DetailsPanel Output section (search)', () => {
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(

View File

@@ -62,7 +62,7 @@ function workspace(id = 'w1'): WorkspaceView {
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, state: 'idle', phase: 'ready', error: null,
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})

View File

@@ -429,7 +429,7 @@ describe('DetailsPanel Output section', () => {
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
@@ -607,7 +607,7 @@ describe('DetailsPanel Output section', () => {
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}))}
useInput={(() => { throw new Error('unused') })}

View File

@@ -190,7 +190,7 @@ describe('DetailsPanel web Output section', () => {
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(

View File

@@ -77,7 +77,7 @@ function mountFrame() {
return sel(sessionState)
}) as never
const workspaceState: WorkspaceListState = {
items: [], state: 'idle', phase: 'ready', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
}
const element = () => (

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79
README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek`reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek`reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base,而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分Models 页仍是诊断界面。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读设置凭据能力不可用时,该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。

View File

@@ -1,7 +1,99 @@
.dialog {
width: min(420px, 100%);
.page {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 64px));
max-height: 100vh;
padding: clamp(104px, 18vh, 156px) 0 40px;
box-sizing: border-box;
overflow-y: auto;
color: var(--dsw-alias-label-primary);
}
.brand {
display: flex;
align-items: center;
margin-bottom: 42px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
outline: none;
}
.description {
margin: 16px 0 0;
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 32px;
}
.primary {
width: 100%;
min-width: 132px;
}
.brand,
.title,
.description,
.actions {
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.title { animation-delay: 40ms; }
.description { animation-delay: 80ms; }
.actions { animation-delay: 120ms; }
@keyframes credential-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.brand,
.title,
.description,
.actions {
animation: none;
}
}
@media (max-width: 560px) {
.page {
width: calc(100vw - 40px);
padding-top: 64px;
}
.brand {
margin-bottom: 30px;
}
.actions {
align-items: stretch;
flex-direction: column-reverse;
margin-top: 32px;
}
.primary,
.later {
width: 100%;
}
}

View File

@@ -1,13 +1,13 @@
/**
* Official-DeepSeek first-run dialog. Readiness comes from the same
* Official-DeepSeek first-run step. Readiness comes from the same
* provider/settings/credential join as the Models page; the prompt only
* routes the user to that page's single credential editor.
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
@@ -37,29 +37,35 @@ function assertNever(_value: never): never {
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the controlled modal or null when onboarding needs no intervention.
* @returns the onboarding page or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { active, openSection, controller, useSnapshot, t } = props
const { complete, openSection, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const readiness = deepSeekReadiness(state)
const [dismissed, setDismissed] = useState(false)
const titleRef = useRef<HTMLHeadingElement | null>(null)
useEffect(() => {
if (active && !dismissed && state.status === 'idle') void controller.load()
}, [active, controller, dismissed, state.status])
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
const close = (): void => {
setDismissed(true)
}
useEffect(() => {
if (
readiness.kind === 'adapter-absent'
|| readiness.kind === 'configured'
|| readiness.kind === 'unavailable'
) complete()
}, [complete, readiness.kind])
useEffect(() => {
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
}, [readiness.kind])
const openModels = (): void => {
close()
complete()
openSection('models')
}
if (!active || dismissed) return null
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
@@ -74,23 +80,25 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
}
return (
<Modal
open
onClose={close}
title={t('onboardingTitle')}
closeLabel={t('onboardingLater')}
description={t('onboardingDescription')}
className={styles['dialog'] as string}
footer={(
<Button
variant="primary"
className={styles['primary']}
autoFocus
onClick={openModels}
>
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
)}
/>
</div>
</section>
)
}

View File

@@ -3,6 +3,7 @@
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.title {
@@ -14,13 +15,13 @@
.intro {
margin: 0;
font-size: 13px;
color: var(--text-tertiary, #888);
color: var(--dsw-alias-label-tertiary);
}
.notice {
margin: 0;
font-size: 12px;
color: var(--text-warning, #a15c00);
color: var(--dsw-alias-state-warn-label);
}
.rows {
@@ -33,13 +34,13 @@
}
.rowCard {
border: 1px solid var(--border, #e2e2e2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--surface, #fff);
background: var(--dsw-alias-bg-layer-3);
}
.rowHead {
@@ -53,58 +54,27 @@
font-weight: 600;
}
.badges {
display: inline-flex;
gap: 6px;
flex: 1;
}
.badgeOk {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--text-success, #0a7d33);
font-size: 12px;
}
.badgeOk::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background: currentcolor;
}
.badgeMuted {
color: var(--text-tertiary, #999);
font-size: 12px;
}
.badgeWarn {
color: var(--text-warning, #a15c00);
font-size: 12px;
}
.rowActions {
display: inline-flex;
gap: 8px;
margin-left: auto;
}
.primaryButton {
border: none;
border-radius: 999px;
padding: 8px 18px;
background: var(--accent-strong, #111);
color: var(--text-inverse, #fff);
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-foreground);
font: inherit;
cursor: pointer;
}
.secondaryButton {
border: 1px solid var(--border, #d9d9d9);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 6px 14px;
background: var(--surface, #fff);
background: var(--dsw-alias-bg-layer-3);
color: inherit;
font: inherit;
cursor: pointer;
@@ -113,7 +83,7 @@
.dangerButton {
border: none;
background: none;
color: var(--text-danger, #c0392b);
color: var(--dsw-alias-state-error-primary);
font: inherit;
cursor: pointer;
}
@@ -126,9 +96,9 @@
}
.editor {
border: 1px solid var(--border, #e6e6e6);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
background: var(--dsw-alias-bg-layer-2);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -148,7 +118,7 @@
.editorRoute {
font-size: 12px;
color: var(--text-tertiary, #999);
color: var(--dsw-alias-label-tertiary);
}
.field {
@@ -163,14 +133,14 @@
gap: 10px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #555);
color: var(--dsw-alias-label-secondary);
}
.linkButton {
border: none;
background: none;
padding: 0;
color: var(--text-tertiary, #888);
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 12px;
text-decoration: underline;
@@ -185,7 +155,7 @@
.advancedHint {
margin: 0;
font-size: 12px;
color: var(--text-tertiary, #999);
color: var(--dsw-alias-label-tertiary);
}
.editorActions {
@@ -202,12 +172,12 @@
.addButton {
align-self: flex-start;
border: 1px solid var(--border, #d9d9d9);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
background: var(--dsw-alias-bg-layer-3);
color: inherit;
cursor: pointer;
}
@@ -219,9 +189,9 @@
.addCard,
.setupCard {
border: 1px solid var(--border, #e6e6e6);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--surface-secondary, #f7f7f8);
background: var(--dsw-alias-bg-layer-3);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -237,7 +207,7 @@
}
.customized {
border-top: 1px solid var(--border, #ececec);
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 10px;
}
@@ -245,7 +215,7 @@
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #555);
color: var(--dsw-alias-label-secondary);
list-style: revert;
}
@@ -259,25 +229,38 @@
.input {
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--border, #d9d9d9);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
font: inherit;
font-size: 13px;
background: var(--surface, #fff);
color: inherit;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
.input:focus {
outline: none;
border-color: var(--accent-strong, #111);
border-color: var(--dsw-alias-brand-primary);
}
.input::placeholder {
color: var(--text-tertiary, #aaa);
color: var(--dsw-alias-label-dimmed);
}
.error {
margin: 0;
font-size: 12px;
color: var(--text-danger, #c0392b);
color: var(--dsw-alias-state-error-primary);
}
.deleteDialog {
width: min(480px, 100%);
}
.deleteConfirm:not(:disabled) {
border-color: var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
}
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}

View File

@@ -4,13 +4,15 @@
* card at a time. A whole-section provider without a configured key (the
* unconfigured DeepSeek posture) renders as its open setup card instead of a
* row; the add flow is a card carrying the dormant-provider select. Every
* mutation writes through the wire; the page re-renders from the pushed
* invalidations or the post-apply reload.
* mutation writes through the wire, while a provider removal first requires
* confirmation; the page re-renders from pushed invalidations or the
* post-apply reload.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
@@ -114,6 +116,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const state = injected.useSnapshot(snapshot => snapshot)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [adding, setAdding] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
@@ -121,6 +125,26 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
if (changed) void controller.load()
}
const closeDelete = (): void => {
if (deleting) return
setDeleteTarget(undefined)
}
const confirmDelete = (): void => {
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
if (deleteTarget === undefined || deleting) return
setDeleting(true)
void removeProviderProfile(api, controller, deleteTarget)
.then((failure) => {
if (failure !== undefined) {
controller.fail(failure)
return
}
setDeleteTarget(undefined)
})
.finally(() => { setDeleting(false) })
}
if (state.status === 'idle') void controller.load()
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
@@ -174,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<li key={row.entry.provider} className={styles['rowCard']}>
<div className={styles['rowHead']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
<span className={styles['badges']}>
{row.entry.active
? <span className={styles['badgeOk']}>{t('active')}</span>
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
</span>
<span className={styles['rowActions']}>
<button
type="button"
@@ -193,11 +212,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
type="button"
className={styles['dangerButton']}
disabled={!state.writable}
onClick={() => {
void removeProviderProfile(api, controller, target).then((failure) => {
if (failure !== undefined) controller.fail(failure)
})
}}
onClick={() => { setDeleteTarget(target) }}
>
{t('remove')}
</button>
@@ -276,6 +291,29 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
</button>
)}
</div>
<Modal
open={deleteTarget !== undefined}
onClose={closeDelete}
title={t('deleteTitle')}
closeLabel={t('close')}
description={t('deleteDescription')}
className={styles['deleteDialog'] as string}
footer={(
<>
<Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>
{t('cancel')}
</Button>
<Button
variant="outline"
className={styles['deleteConfirm']}
disabled={deleting}
onClick={confirmDelete}
>
{deleting ? t('deleting') : t('deleteConfirm')}
</Button>
</>
)}
/>
</div>
)
}

View File

@@ -5,12 +5,15 @@ export const en = {
nav: 'Models',
title: 'Models',
intro: 'Enter your API keys to use models from the following providers.',
active: 'Active',
dormant: 'Inactive',
edit: 'Edit',
remove: 'Delete',
deleteTitle: 'Delete model provider?',
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
deleteConfirm: 'Delete provider',
deleting: 'Deleting provider…',
add: 'Add provider',
provider: 'Provider',
close: 'Close',
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
@@ -42,12 +45,15 @@ export const zh: typeof en = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
active: '已启用',
dormant: '未启用',
edit: '编辑',
remove: '删除',
deleteTitle: '删除模型提供方?',
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
deleteConfirm: '删除提供方',
deleting: '正在删除提供方…',
add: '添加提供方',
provider: '提供方',
close: '关闭',
cancel: '取消',
apply: '保存',
applying: '保存中…',

View File

@@ -48,6 +48,7 @@ describe('ui-models apply', () => {
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
expect(injected.t('nav')).toBe('模型')
expect(injected.t('deleteTitle')).toBe('删除模型提供方?')
expect(typeof injected.controller.load).toBe('function')
expect(typeof injected.useSnapshot).toBe('function')
expect(injected.api).toBeDefined()
@@ -73,8 +74,11 @@ describe('ui-models apply', () => {
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
b.locale.setLocale('zh')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
expect(injected().t('deleteTitle')).toBe('删除模型提供方?')
})
it('locale change while the slot is undeclared stays a no-op', async () => {

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from 'schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -152,10 +152,9 @@ describe('ModelsSection', () => {
// DeepSeek has no configured credential and no stored apiKey → setup card.
expect(screen.getByText('DeepSeek')).toBeTruthy()
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
// Configured pi-ai profiles render as rows with liveness badges only.
expect(screen.getByText('openai')).toBeTruthy()
expect(screen.getAllByText(en.active)).toHaveLength(1)
expect(screen.getByText(en.dormant)).toBeTruthy()
expect(screen.queryByText('Active')).toBeNull()
expect(screen.queryByText('Inactive')).toBeNull()
expect(screen.getByText(`+ ${en.add}`)).toBeTruthy()
})
@@ -471,10 +470,28 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
})
it('removes a user-added provider by unsetting its path', async () => {
it('requires confirmation before removing a user-added provider', async () => {
const { replace, mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
expect(dialog.textContent).toContain(en.deleteDescription)
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.close }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
@@ -482,6 +499,28 @@ describe('ModelsSection', () => {
})
})
it('blocks duplicate deletion while the confirmed removal is pending', async () => {
let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
resolveRemoval = resolve
}))
await mountSection({ mutate })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(mutate).toHaveBeenCalledOnce()
expect(confirm.disabled).toBe(true)
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
expect(mutate).toHaveBeenCalledOnce()
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
})
it('renders the load failure with a retry control', async () => {
const face = scriptedFace()
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
@@ -589,6 +628,8 @@ describe('ModelsSection', () => {
// would appear — rather than the row silently staying put.
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
await screen.findByText(`${en.loadFailed}: the host refused`)
})

View File

@@ -24,8 +24,8 @@ function fail<T>(message: string): RpcResponse<T> {
function harness(options: {
provider?: boolean
providerActive?: boolean
providerSettingsNs?: string
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
@@ -40,9 +40,7 @@ function harness(options: {
const face = {
llm: {
providers: () => {
if (options.providersReject === true) {
return Promise.reject(new Error('provider transport unavailable'))
}
if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable'))
return Promise.resolve(ok({
providers: options.provider === false
? []
@@ -91,9 +89,11 @@ function harness(options: {
}
const controller = new ModelsSettingsStore(face as never)
const openSection = vi.fn()
const complete = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: DeepSeekOnboardingDialogProps = {
active: true,
stepId: 'deepseek-official',
complete,
openSection,
useSessions: unusedHook,
useWorkspaces: unusedHook,
@@ -101,36 +101,36 @@ function harness(options: {
useSnapshot: bindSnapshotSelector(controller.store),
t: key => en[key],
}
return { controller, openSection, props, configure: () => { fileConfigured = true } }
return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents one accessible route to Models', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
expect(action).toBeTruthy()
expect(document.activeElement).toBe(action)
expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
expect(screen.queryByRole('textbox')).toBeNull()
})
it('opens the Models section and dismisses the prompt', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).toHaveBeenCalledWith('models')
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
})
it('allows configure-later dismissal without opening settings', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).not.toHaveBeenCalled()
})
@@ -146,7 +146,8 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('region')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
expect(h.openSection).not.toHaveBeenCalled()
view.unmount()
}
@@ -161,7 +162,8 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('region')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
view.unmount()
}
})
@@ -169,18 +171,10 @@ describe('DeepSeekOnboardingDialog', () => {
it('closes when an external credential invalidation refreshes the shared join', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
h.configure()
await act(async () => { await h.controller.load() })
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
})
it('stays hidden while the onboarding owner is inactive', async () => {
const h = harness()
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
expect(h.complete).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
describe('ModelsSection theme styles', () => {
it('uses the shared theme tokens without light-only fallbacks', () => {
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
expect(css).toContain('background: var(--dsw-alias-bg-layer-3)')
expect(css).toContain('color: var(--dsw-alias-label-primary)')
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md
README.md: 3377a1c5907b67b065879b012923427685c106d6
README.zh.md: 34cf6f72394632968ded1671a5ac0377e5c78cc6
README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4
README.zh.md: e4b39567e4e39d74fd4d527ed2fcfed8d5318a59

View File

@@ -2,13 +2,15 @@
English | [中文](README.zh.md)
Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write``Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row).
Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session. Choosing Full access requires an explicit risk acknowledgement before the row writes it.
The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write``Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
The `/client` export surface is the plugin body (`apply`/`inject`).
## Model Experience
Indirectly, through the host `/permission` command the picker submits: a switch appends the whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), which select the sandbox mode and approval policy later tool calls resolve. Picker interaction adds no prompt content.
Indirectly, through the permission facts written by its two surfaces: the Settings row causes a future session to start with whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), while the `/permission` picker appends the same facts when it switches the current session; those events select the sandbox mode and approval policy later tool calls resolve, and picker interaction adds no prompt content.
#### KV Cache effect
@@ -16,4 +18,4 @@ No direct invalidation; the knob consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **No keyless snapshot exercises the picker yet** — the popup flow is covered by unit specs over fake faces; the assembled-transcript scenario rides the deferred approval/preset e2e work.
- **The Settings row is Web-only** — non-Web clients may still switch the current session through `/permission`, but do not receive this browser contribution.

View File

@@ -2,13 +2,15 @@
[English](README.md) | 中文
权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.command.decorate`。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 activekebab-case 预设名渲染为 Title Case 标签(`workspace-write``Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select因此两个界面共享同一读源与同一写路径推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.command.decorate`。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 activekebab-case 预设名渲染为 Title Case 标签(`workspace-write``Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select因此两个当前会话界面共享同一读源与同一写路径推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
`/client` 导出面为插件本体(`apply`/`inject`)。
## Model Experience
间接影响,经由选择框提交的 host `/permission` 命令:一次切换追加全量值旋钮事件(`permission/preset``sandbox/mode``approval/policy`决定后续工具调用解析到的沙箱模式与审批策略选择框交互本身不添加任何提示词内容。
通过两个界面写入的权限事实间接影响Settings 行使未来会话带着全量值旋钮事件(`permission/preset``sandbox/mode``approval/policy`启动,而 `/permission` 选择框切换当前会话时会追加相同的事实;这些事件决定后续工具调用解析到的沙箱模式与审批策略选择框交互本身不添加任何提示词内容。
#### KV Cache effect
@@ -16,4 +18,4 @@
## Known Limitations and Deferred Work
- **尚无无密钥快照覆盖选择框** —— popup 流程由基于 fake face 的单元 spec 覆盖;组装态转写场景随延后的审批/预设 e2e 工作一并补齐
- **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-permission",
"description": "Permission preset selection: the /permission popupSelect over the permissions projection and the host /permission command",
"description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
@@ -36,22 +37,34 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,60 @@
/* Permission row: title/description plus the preset selector pill. */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled {
cursor: default;
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,133 @@
/**
* Permission preference row: the default preset for subsequently created
* sessions. Current-session switches remain on the composer `/permission`
* control.
*/
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
IconChevronDownOutline14, Menu, RiskConfirmation,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PermissionSettingsState } from './settings-store.ts'
import type { PermissionSettingsKey } from './locales.ts'
import { FULL_ACCESS_PRESET } from './presentation.ts'
import css from './PermissionRow.module.css'
/** Registration-side business face for the host-backed preference. */
export interface PermissionRowInjected {
hooks: {
/** Permission settings snapshot bound by the renderer as usePermission. */
permission: SnapshotStore<PermissionSettingsState>
}
/** Load the descriptor when the row first renders. */
load: () => Promise<void>
/** Persist one advertised preset. */
select: (preset: string) => Promise<void>
}
/** Full component props. */
export type PermissionRowProps =
PropsRuntime<'settings.general.item'>
& PropsLocale<'settings.permission'>
& InjectFace<PermissionRowInjected>
/**
* Render the new-session Permission default selector.
* @param props - composed slot props.
* @returns the row, or null when the host does not expose permission settings.
*/
export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) {
const state = usePermission(snapshot => snapshot)
const [open, setOpen] = useState(false)
const [confirmingFullAccess, setConfirmingFullAccess] = useState(false)
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (state.writable && state.status !== 'unavailable') return
setOpen(false)
setAcknowledged(false)
setConfirmingFullAccess(false)
}, [state.status, state.writable])
if (state.status === 'unavailable') return null
const selected = state.options.find(option => option.id === state.currentValue)
const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess
const label = selected?.label
?? (busy ? t('loading') : t('unavailable'))
const description: string = state.error ?? t('description')
return (
<>
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('title')}</div>
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({ id: option.id, label: option.label }))}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)
if (id === state.currentValue) return
if (id === FULL_ACCESS_PRESET) {
setAcknowledged(false)
setConfirmingFullAccess(true)
return
}
void select(id)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
disabled={busy || !state.writable || state.options.length === 0}
onClick={() => { setOpen(value => !value) }}
>
{label}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
<RiskConfirmation
open={confirmingFullAccess}
title={t('confirm.title')}
description={t('confirm.description')}
acknowledgeLabel={t('confirm.acknowledge')}
cancelLabel={t('confirm.cancel')}
confirmLabel={t('confirm.enable')}
acknowledged={acknowledged}
disabled={!state.writable || state.status === 'saving'}
onAcknowledgedChange={setAcknowledged}
onCancel={() => {
setAcknowledged(false)
setConfirmingFullAccess(false)
}}
onConfirm={() => {
setAcknowledged(false)
setConfirmingFullAccess(false)
void select(FULL_ACCESS_PRESET)
}}
/>
</>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Permission row copy. */
'settings.permission': PermissionSettingsKey
}
}

View File

@@ -10,18 +10,37 @@
* write through one path and the pushed projection frame is the one
* confirmation. The Full access row carries the same explicit risk gate as
* the composer chip; the shared popup shell owns the modal mechanics.
* The General-settings row separately writes the default preset for sessions
* created later through the host Settings API.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import { PermissionRow } from './PermissionRow.tsx'
import type { PermissionRowInjected } from './PermissionRow.tsx'
import {
accessEn, accessZh, en, zh,
} from './locales.ts'
import {
displayPermissionPreset, FULL_ACCESS_PRESET,
} from './presentation.ts'
import {
PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded,
} from './settings-store.ts'
export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx'
export type {
PermissionDefaultOption, PermissionSettingsState,
} from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['command', 'sessions', 'locale']
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
const FULL_ACCESS = 'danger-full-access'
const ACCESS_NS = 'permission.access'
/** Read one session's current permissions projection value (undefined = capability absent). */
@@ -29,28 +48,16 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine
return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined
}
/**
* Display transform twin of the composer chip's (ui-conversation
* PermissionSelect): kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
* pass through. Full access intentionally uses the product label rather than
* a title-cased machine value; its warning body remains locale-aware.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
/** Flatten the projection select into popup rows; `custom` is display state, never a target. */
function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] {
return value.options
.filter(option => option.value !== 'custom')
.map(option => ({
id: option.value,
label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name),
label: displayPermissionPreset(option.value, option.name),
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
...(option.value === FULL_ACCESS
...(option.value === FULL_ACCESS_PRESET
? {
confirmation: {
title: t('confirm.title'),
@@ -78,18 +85,18 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register(ACCESS_NS, 'zh', {
'confirm.title': '确认启用 Full access',
'confirm.description': '启用 Full access 后agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
'confirm.title': accessZh['confirm.title'],
'confirm.description': accessZh['confirm.description'],
'confirm.acknowledge': accessZh['confirm.acknowledge'],
'confirm.cancel': accessZh['confirm.cancel'],
'confirm.enable': accessZh['confirm.enable'],
}),
ctx.locale.register(ACCESS_NS, 'en', {
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'confirm.acknowledge': 'I understand the risks and want to continue',
'confirm.cancel': 'Cancel',
'confirm.enable': 'Enable Full access',
'confirm.title': accessEn['confirm.title'],
'confirm.description': accessEn['confirm.description'],
'confirm.acknowledge': accessEn['confirm.acknowledge'],
'confirm.cancel': accessEn['confirm.cancel'],
'confirm.enable': accessEn['confirm.enable'],
}),
]
return () => { for (const dispose of disposers) dispose() }
@@ -98,6 +105,46 @@ export function apply(ctx: ClientContext): void {
const t = ctx.locale.bind(ACCESS_NS)
const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
sessions.binding(session.sessionId)?.session
ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new PermissionSettingsController(connection.api)
const load = (): Promise<void> => controller.load()
const select = (preset: string): Promise<void> => controller.select(preset)
const injected = (): PermissionRowInjected => ({
hooks: { permission: controller.store },
load,
select,
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return
refreshPermissionIfLoaded(controller)
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
return () => {
controller.dispose()
for (const dispose of disposers) dispose()
}
}, 'ui-permission: settings invalidations')
ctx.effect(() => {
const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'permission',
order: -20,
locale: 'settings.permission',
inject: injected,
}, PermissionRow))
return () => { row.dispose() }
}, 'ui-permission: General settings row')
ctx.effect(() => command.decorate({
name: 'permission',
// The picker exists exactly while the projection does: a permission-less

View File

@@ -0,0 +1,51 @@
/** `settings.permission` namespace dictionaries (the Permission row's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'title': '权限',
'description': '选择新会话的默认权限模式',
'loading': '加载中',
'unavailable': '不可用',
'confirm.title': '确认启用 Full access',
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
} satisfies Record<string, string>
/** The settings.permission namespace key union. */
export type PermissionSettingsKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'title': 'Permission',
'description': 'Choose the default permission mode for new sessions',
'loading': 'Loading',
'unavailable': 'Unavailable',
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
'confirm.acknowledge': 'I understand the risks and want to continue',
'confirm.cancel': 'Cancel',
'confirm.enable': 'Enable Full access',
} satisfies Record<PermissionSettingsKey, string>
/** Simplified Chinese dictionary for the current-session popup gate. */
export const accessZh = {
'confirm.title': '确认启用 Full access',
'confirm.description': '启用 Full access 后agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
} satisfies Record<string, string>
/** Current-session popup-gate key union. */
export type PermissionAccessKey = keyof typeof accessZh
/** English dictionary for the current-session popup gate. */
export const accessEn = {
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'confirm.acknowledge': 'I understand the risks and want to continue',
'confirm.cancel': 'Cancel',
'confirm.enable': 'Enable Full access',
} satisfies Record<PermissionAccessKey, string>

View File

@@ -0,0 +1,22 @@
/** Machine value of the preset that requires an explicit GUI risk gate. */
export const FULL_ACCESS_PRESET = 'danger-full-access'
/**
* Convert conventional kebab-case preset names into user-facing title case.
* @param name - host-supplied preset label or key.
* @returns the title-cased conventional key, or a non-kebab label unchanged.
*/
export function displayPresetName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
/**
* Render a permission preset under its product label.
* @param value - preset machine value.
* @param name - host-supplied preset name.
* @returns the Full access product label or the conventional display name.
*/
export function displayPermissionPreset(value: string, name: string): string {
return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
}

View File

@@ -0,0 +1,191 @@
/**
* Permission default-settings controller. The host descriptor supplies the
* current value and the dynamic preset enum; writes target only
* `defaultPreset` and carry the descriptor revision.
*/
import type {
IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
nodeAtPath, rehydrateSchema, type SchemaNode,
} from '@deepseek-ai/dsh-client-schema-form'
import { displayPermissionPreset } from './presentation.ts'
/** Permission's settings namespace on the host wire. */
export const PERMISSION_SETTINGS_NS = 'permission'
/** One selectable new-session default. */
export interface PermissionDefaultOption {
/** Preset key written to Settings. */
id: string
/** Host-supplied label or a title-cased preset key. */
label: string
}
/** Permission settings-row snapshot. */
export interface PermissionSettingsState {
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
error: string | null
writable: boolean
currentValue: string
options: readonly PermissionDefaultOption[]
revision: number
}
interface ConstChoice {
type: string
value?: unknown
meta?: { description?: unknown }
}
/**
* Read the dynamic preset enum encoded by the host's `defaultPreset` schema.
* @param view - permission namespace descriptor.
* @returns current value and selectable options.
*/
export function permissionDefaultOf(view: SettingsNamespaceView): {
currentValue: string
options: PermissionDefaultOption[]
} {
const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset
if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value')
const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset'])
if (node === undefined) throw new Error('permission settings schema has no defaultPreset field')
const rawChoices = node.type === 'union'
? (node.list as SchemaNode[] | undefined) ?? []
: [node]
const options = rawChoices.flatMap((candidate) => {
const choice = candidate as unknown as ConstChoice
if (choice.type !== 'const' || typeof choice.value !== 'string') return []
const described = choice.meta?.description
return [{
id: choice.value,
label: typeof described === 'string' && described.length > 0
? displayPermissionPreset(choice.value, described)
: displayPermissionPreset(choice.value, choice.value),
}]
})
if (options.length === 0 || !options.some(option => option.id === value)) {
throw new Error('permission settings schema does not advertise its current preset')
}
return { currentValue: value, options }
}
/** Controller joining Settings reads, writes, and pushed invalidations. */
export class PermissionSettingsController {
/** Row snapshot consumed through a bound selector hook. */
readonly store: SnapshotStore<PermissionSettingsState> = createSnapshotStore({
status: 'idle',
error: null,
writable: false,
currentValue: '',
options: [],
revision: 0,
})
private generation = 0
private view: SettingsNamespaceView | undefined
/** @param api - Settings wire face. */
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/**
* Refresh the permission descriptor. Latest request wins.
* @returns nothing; {@link store} carries success or failure.
*/
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((state) => {
state.status = 'loading'
state.error = null
})
try {
const response = await this.api.settings.describe({})
if (!response.result.ok) throw new Error(response.result.error.message)
if (generation !== this.generation) return
const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS)
if (view === undefined) {
this.view = undefined
this.store.update((state) => {
state.status = 'unavailable'
state.writable = false
state.currentValue = ''
state.options = []
})
return
}
this.accept(view, response.result.value.writable)
} catch (error) {
if (generation !== this.generation) return
this.fail(error)
}
}
/**
* Persist one preset as the default for subsequently created sessions.
* @param preset - advertised preset key.
* @returns nothing; {@link store} carries success or failure.
*/
async select(preset: string): Promise<void> {
const view = this.view
const state = this.store.getSnapshot()
if (view === undefined || !state.writable) return
const generation = ++this.generation
this.store.update((draft) => {
draft.status = 'saving'
draft.error = null
})
try {
const response = await this.api.settings.mutate({
ns: PERMISSION_SETTINGS_NS,
ops: [{ op: 'set', path: ['defaultPreset'], value: preset }],
expectedRevision: view.revision,
})
if (generation !== this.generation) return
if (!response.result.ok) throw new Error(response.result.error.message)
this.accept(response.result.value, true)
} catch (error) {
if (generation !== this.generation) return
this.fail(error)
}
}
/** Stop in-flight responses from publishing after plugin disposal. */
dispose(): void {
this.generation += 1
this.view = undefined
}
private accept(view: SettingsNamespaceView, writable: boolean): void {
const resolved = permissionDefaultOf(view)
this.view = view
this.store.update((state) => {
state.status = 'ready'
state.error = null
state.writable = writable
state.currentValue = resolved.currentValue
state.options = resolved.options
state.revision = view.revision
})
}
private fail(error: unknown): void {
this.store.update((state) => {
state.status = 'error'
state.error = error instanceof Error ? error.message : String(error)
})
}
}
/**
* Refetch only after the row has opened once.
* @param controller - permission settings controller.
*/
export function refreshPermissionIfLoaded(controller: PermissionSettingsController): void {
if (controller.store.getSnapshot().status === 'idle') return
void controller.load()
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -1,8 +1,8 @@
/**
* Permission preset selection plugin, node half. Pure UI plugin: the empty
* apply exists so the plugin appears in the host cordis.yml / Loader; the
* browser half ships via exports["./client"], discovered through the
* package.json dshClient declaration.
* Permission surfaces plugin, node half. The empty apply exists so the plugin
* appears in the host cordis.yml / Loader; the browser half ships the
* new-session Settings row and current-session command picker through
* exports["./client"], discovered from the package.json dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */

View File

@@ -15,9 +15,9 @@ export const name = 'client-ui-permission-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a single command contribution registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
* No runtime invariant: the command and slot contribution lifecycles are
* proven by the HMR-safety spec, while the browser-only Settings controller
* owns no host events or cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}

View File

@@ -5,14 +5,20 @@
* the current value active and `custom` excluded; availability follows the
* projection key's presence; a pick submits the /permission line through
* Session.command and surfaces rejection/unmatched as thrown errors; fiber
* disposal removes the contribution (HMR safety).
* disposal removes the contribution (HMR safety). The same plugin registers
* its Settings row and invalidates that row on host settings changes.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import {
PermissionRow, type PermissionRowInjected,
} from '../src/client/PermissionRow.tsx'
import { apply, inject } from '../src/client/index.ts'
import { accessEn } from '../src/client/locales.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -27,6 +33,27 @@ const SELECT: PermissionSelect = {
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService)
const locale = new LocaleService(ctx)
locale.setLocale('en')
ctx.provide('locale', locale)
ctx.slots.register({
name: 'root',
children: {
'settings.general.item': { kind: 'list', scope: 'root' },
},
} as never, () => null)
ctx.provide('connection', {
api: {
settings: {
describe: () => Promise.resolve({
rpcId: 'describe',
result: { ok: true as const, value: { writable: true, namespaces: [] } },
}),
mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
},
},
} as never)
let decoration: CommandDecoration | undefined
ctx.provide('command', {
decorate(c: CommandDecoration) {
@@ -54,23 +81,14 @@ async function bench() {
ctx.provide('sessions', {
binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined),
})
const en = {
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access can perform sensitive operations.',
'confirm.acknowledge': 'I understand the risks and want to continue',
'confirm.cancel': 'Cancel',
'confirm.enable': 'Enable Full access',
} as Record<string, string>
ctx.provide('locale', {
register: () => () => {},
bind: () => (key: string) => en[key] ?? key,
})
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return {
ctx, fiber, values, commands,
setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
decoration: () => decoration,
permissionRow: () => ctx.slots.entries('settings.general.item')
.find(entry => entry.component === PermissionRow),
}
}
@@ -80,6 +98,14 @@ describe('ui-permission browser plugin', () => {
const c = b.decoration()!
expect(c.name).toBe('permission')
expect(c.ui.kind).toBe('popupSelect')
const row = b.permissionRow()!
expect(row.options).toEqual({ id: 'permission', order: -20 })
const injected = row.inject?.() as PermissionRowInjected | undefined
expect(injected?.hooks.permission).toBeDefined()
expect(typeof injected?.load).toBe('function')
expect(typeof injected?.select).toBe('function')
await injected!.load()
await injected!.select('read-only')
})
it('availability follows the projection key; options mark the current value active and exclude custom', async () => {
@@ -100,7 +126,7 @@ describe('ui-permission browser plugin', () => {
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
title: 'Enable Full access?',
description: 'Full access can perform sensitive operations.',
description: accessEn['confirm.description'],
acknowledgeLabel: 'I understand the risks and want to continue',
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
@@ -132,7 +158,11 @@ describe('ui-permission browser plugin', () => {
it('disposal removes the decoration (HMR safety)', async () => {
const b = await bench()
expect(b.decoration()).toBeDefined()
b.ctx.emit('settings/changed', 'another')
b.ctx.emit('settings/changed', 'permission')
b.ctx.emit('connection/reset')
await b.fiber.dispose()
expect(b.decoration()).toBeUndefined()
expect(b.permissionRow()).toBeUndefined()
})
})

View File

@@ -0,0 +1,157 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
import { en } from '../src/client/locales.ts'
import { PermissionSettingsController } from '../src/client/settings-store.ts'
afterEach(cleanup)
const SCHEMA = {
uid: 5,
refs: {
1: { type: 'const', value: 'read-only' },
2: { type: 'const', value: 'workspace-write' },
3: { type: 'const', value: 'danger-full-access' },
4: { type: 'union', list: [1, 2, 3] },
5: { type: 'object', dict: { defaultPreset: 4 } },
},
}
function view(defaultPreset: string, revision = 0): SettingsNamespaceView {
return {
ns: 'permission',
schema: SCHEMA,
value: { defaultPreset },
base: { defaultPreset: 'read-only' },
applies: 'live',
secrets: [],
revision,
}
}
function ok<T>(value: T) {
return { rpcId: 'test', result: { ok: true as const, value } }
}
const dictionary: Record<string, string> = en
const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
const runtime = {
useSessions: (() => { throw new Error('unused') }) as never,
useWorkspaces: (() => { throw new Error('unused') }) as never,
}
function mount(controller: PermissionSettingsController) {
return render(
<PermissionRow
{...runtime}
load={() => controller.load()}
select={preset => controller.select(preset)}
usePermission={bindSnapshotSelector(controller.store)}
t={t}
/>,
)
}
describe('PermissionRow', () => {
it('loads the descriptor, opens the menu, and selects a new default', async () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
mutate,
} as never,
})
mount(controller)
const button = await screen.findByRole('button', { name: 'Read Only' })
expect(button.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
fireEvent.click(button)
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
await screen.findByRole('button', { name: 'Workspace Write' })
expect(mutate).toHaveBeenCalledOnce()
})
it('requires explicit acknowledgement before saving Full access', async () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
mutate,
} as never,
})
mount(controller)
fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Read Only' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' })
const enable = screen.getByRole('button', { name: 'Enable Full access' })
expect((enable as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(enable)
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
expect(dialog.isConnected).toBe(false)
})
it('hides an unavailable namespace and disables a read-only provider', async () => {
const absent = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })),
mutate: vi.fn(),
} as never,
})
const rendered = mount(absent)
await waitFor(() => { expect(rendered.container.textContent).toBe('') })
rendered.unmount()
const readonly = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })),
mutate: vi.fn(),
} as never,
})
mount(readonly)
expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
})
it('shows loading and a contained write error', async () => {
const describe = Promise.withResolvers<ReturnType<typeof ok<{
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
const controller = new PermissionSettingsController({
settings: {
describe: () => describe.promise,
mutate: () => Promise.resolve({
rpcId: 'test',
result: {
ok: false as const,
error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} },
},
}),
} as never,
})
mount(controller)
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
describe.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: 'Read Only' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
})
})

View File

@@ -0,0 +1,254 @@
import { describe, expect, it, vi } from 'vitest'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {
PermissionSettingsController, permissionDefaultOf, refreshPermissionIfLoaded,
} from '../src/client/settings-store.ts'
const SCHEMA = {
uid: 6,
refs: {
1: { type: 'const', value: 'read-only' },
2: { type: 'const', meta: { description: 'Workspace' }, value: 'workspace-write' },
3: { type: 'union', list: [1, 2] },
6: { type: 'object', dict: { defaultPreset: 3 } },
},
}
function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView {
return {
ns: 'permission',
schema,
value: { defaultPreset },
base: { defaultPreset: 'read-only' },
applies: 'live',
secrets: [],
revision,
}
}
function ok<T>(value: T) {
return { rpcId: 'test', result: { ok: true as const, value } }
}
describe('permission settings store', () => {
it('derives dynamic options and host labels from the descriptor schema', () => {
expect(permissionDefaultOf(view('read-only'))).toEqual({
currentValue: 'read-only',
options: [
{ id: 'read-only', label: 'Read Only' },
{ id: 'workspace-write', label: 'Workspace' },
],
})
const single = {
uid: 2,
refs: {
1: { type: 'const', meta: { description: '' }, value: 'read-only' },
2: { type: 'object', dict: { defaultPreset: 1 } },
},
}
expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({
currentValue: 'read-only',
options: [{ id: 'read-only', label: 'Read Only' }],
})
const undescribed = {
uid: 2,
refs: {
1: { type: 'const', meta: { description: 7 }, value: 'read-only' },
2: { type: 'object', dict: { defaultPreset: 1 } },
},
}
expect(permissionDefaultOf(view('read-only', 0, undescribed)).options)
.toEqual([{ id: 'read-only', label: 'Read Only' }])
})
it('rejects malformed values and dynamic enums at the wire boundary', () => {
expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/)
expect(() => permissionDefaultOf(view('read-only', 0, {
uid: 1, refs: { 1: { type: 'object', dict: {} } },
}))).toThrow(/no defaultPreset field/)
expect(() => permissionDefaultOf(view('read-only', 0, {
uid: 2,
refs: {
1: { type: 'union' },
2: { type: 'object', dict: { defaultPreset: 1 } },
},
}))).toThrow(/does not advertise/)
expect(() => permissionDefaultOf(view('read-only', 0, {
uid: 4,
refs: {
1: { type: 'string' },
2: { type: 'const', value: 1 },
3: { type: 'union', list: [1, 2] },
4: { type: 'object', dict: { defaultPreset: 3 } },
},
}))).toThrow(/does not advertise/)
expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/)
})
it('loads and writes defaultPreset with optimistic concurrency', async () => {
const describe = vi.fn(() => Promise.resolve(ok({
writable: true,
namespaces: [view('read-only', 4)],
})))
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
const controller = new PermissionSettingsController({
settings: { describe, mutate } as never,
})
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({
status: 'ready',
writable: true,
currentValue: 'read-only',
revision: 4,
})
await controller.select('workspace-write')
expect(mutate).toHaveBeenCalledWith({
ns: 'permission',
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
expectedRevision: 4,
})
expect(controller.store.getSnapshot()).toMatchObject({
status: 'ready',
currentValue: 'workspace-write',
revision: 5,
})
})
it('hides the row when the namespace is absent and contains write failures', async () => {
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] })))
const controller = new PermissionSettingsController({
settings: { describe, mutate: vi.fn() } as never,
})
await controller.load()
expect(controller.store.getSnapshot().status).toBe('unavailable')
const failing = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
mutate: () => Promise.resolve({
rpcId: 'test',
result: {
ok: false as const,
error: { code: 'settings-conflict', message: 'stale', details: {} },
},
}),
} as never,
})
await failing.load()
await failing.select('workspace-write')
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' })
})
it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => {
const first = Promise.withResolvers<ReturnType<typeof ok<{
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
const describe = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] }))
const mutate = vi.fn()
const controller = new PermissionSettingsController({
settings: { describe, mutate } as never,
})
const stale = controller.load()
await controller.load()
first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] }))
await stale
expect(controller.store.getSnapshot()).toMatchObject({
currentValue: 'read-only',
writable: false,
revision: 2,
})
await controller.select('workspace-write')
expect(mutate).not.toHaveBeenCalled()
const rejected = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve({
rpcId: 'test',
result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } },
}),
mutate,
} as never,
})
await rejected.select('workspace-write')
await rejected.load()
expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' })
const thrown = new PermissionSettingsController({
settings: {
// Promise consumers must contain unknown rejection values from a
// transport implementation, including non-Error legacy clients.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
describe: () => Promise.reject('disconnected'),
mutate,
} as never,
})
await thrown.load()
expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' })
})
it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => {
const read = Promise.withResolvers<ReturnType<typeof ok<{
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
const describe = vi.fn(() => read.promise)
const idle = new PermissionSettingsController({ settings: { describe, mutate: vi.fn() } as never })
refreshPermissionIfLoaded(idle)
expect(describe).not.toHaveBeenCalled()
const loading = idle.load()
idle.dispose()
read.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
await loading
expect(idle.store.getSnapshot().status).toBe('loading')
const rejectedRead = Promise.withResolvers<ReturnType<typeof ok<{
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
const disposedRead = new PermissionSettingsController({
settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never,
})
const reading = disposedRead.load()
disposedRead.dispose()
rejectedRead.reject(new Error('late read'))
await reading
expect(disposedRead.store.getSnapshot().status).toBe('loading')
const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const activeDescribe = vi.fn(() => Promise.resolve(ok({
writable: true,
namespaces: [view('read-only')],
})))
const active = new PermissionSettingsController({
settings: {
describe: activeDescribe,
mutate: () => mutation.promise,
} as never,
})
await active.load()
refreshPermissionIfLoaded(active)
await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) })
const saving = active.select('workspace-write')
active.dispose()
mutation.resolve(ok(view('workspace-write', 1)))
await saving
expect(active.store.getSnapshot().status).toBe('saving')
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const disposedWrite = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
mutate: () => rejectedMutation.promise,
} as never,
})
await disposedWrite.load()
const writing = disposedWrite.select('workspace-write')
disposedWrite.dispose()
rejectedMutation.reject(new Error('late write'))
await writing
expect(disposedWrite.store.getSnapshot().status).toBe('saving')
})
})

View File

@@ -8,18 +8,36 @@
"src"
],
"references": [
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
},
{
"path": "../schema-form"
},
{
"path": "../ui-command"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slash"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../../ui/permission"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 30e2706a9001bdd628ddea9d592c2c8459a4ff21
README.zh.md: fd4ed3d402cca44b0552e6ab07624d80d6492e72
README.md: 6430a789c15634538a38d6581df50a489522db55
README.zh.md: 78249612cce3148fcececded40c529682450460a

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, SearchBlock, DiffBlock, and WebBlock. Contract: api-contracts v3 §8.
## Markdown rendering
@@ -16,6 +16,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md).
## Diff rendering
`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md).
## Web retrieval
`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `<li value>`, and the expand control is a marker-less `<li>` so the `<ol>` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `<ol>` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量、TerminalBlock、SearchBlock以及 WebBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量、TerminalBlock、SearchBlock、DiffBlock,以及 WebBlock。契约api-contracts v3 §8。
## Markdown 渲染
@@ -15,6 +15,10 @@
`SearchBlock` 渲染一次已完成的搜索,一个组件绘制两种 kind(由 `kind` 判别)。`matches`(grep)把每个文件渲染为粗体路径头加其 `lineNumber: line` 行,每个文件组可折叠;`paths`(glob)渲染扁平路径列表。两者都摊平成一个行列表,由高度上限做头/尾切片(默认 16,与 TerminalBlock 相同的切分算法),且都不软换行——长匹配行或路径横向滚动而非折行。当工具截断结果时,banner 摘要把截断前总数折入(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断结果呈现为完整;复制控件写入完整结构化结果,无论是否触及上限或哪些组被折叠。几何镜像 CodeBlock/TerminalBlock。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
## Diff 渲染
`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `error token在新增行`+ `success token之上、同文件第二个 hunk 前一个 `⋯` gap以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16`TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap使多文件复制保持可归属并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock``+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。
## Web 检索
`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind`kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL时回退到原始 URL因此标签绝不为空其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `<li value>` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `<li>`,使 `<ol>` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`chat 行不呈现原始 result content`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。

View File

@@ -0,0 +1,107 @@
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface +
banner row, markdown code-block font) so a diff card reads as one family with
a fenced block and a terminal card. The deliberate divergence, shared with
TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally,
because folding a source line destroys the indentation a diff is read by. */
.block {
--dsl-diff-radius: 12px;
--dsl-diff-line-height: 22px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-diff-radius);
}
/* The copy control floats in the top-right corner over the body, so the card
has no empty banner row above its first diff line (the TUI diff card has no
banner either — only the footer). The block is position: relative, so this
anchors to the card. */
.copyButton {
position: absolute;
top: 8px;
right: 12px;
z-index: 1;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 14px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: a diff is read by its indentation. */
.line {
min-height: var(--dsl-diff-line-height);
white-space: pre;
}
/* A file header: the path in the primary tone, set apart by weight. The copy
button floats over this first row's top-right corner, so reserve space at the
line's end for it — a long path scrolls under the button otherwise, and the
button's hit area would eat clicks on the path's tail. */
.path {
color: var(--dsw-alias-label-primary);
font-weight: 600;
padding-right: 56px;
}
/* A same-file second hunk's separator (a scattered edit), in the dim tone. */
.gap {
color: var(--dsw-alias-label-tertiary);
}
/* The diff's own meaning-carrying colors: removed on the error token, added on
the success token. A `- `/`+ ` prefix is drawn here so a copied line and the
shown line agree, and so the sign reads without relying on color alone. */
.del::before {
content: '- ';
color: var(--dsw-alias-state-error-primary);
}
.del {
color: var(--dsw-alias-state-error-primary);
}
.add::before {
content: '+ ';
color: var(--dsw-alias-state-success-primary);
}
.add {
color: var(--dsw-alias-state-success-primary);
}
.expand {
display: block;
width: 100%;
padding: 0;
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same
footer the TUI transcript's diff card draws. */
.footer {
padding: 0 14px 12px;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,196 @@
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
// control over one or more per-file hunks, each a bold path header followed by
// the removed block (`-`, error color) and the added block (`+`, success
// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors
// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads
// the same across front ends: the removed side is the old text in full, the
// added side the new text in full, both split on the same terminator rule, and
// the footer counts distinct paths on both ends. Output never soft-wraps — an
// aligned source line keeps its indentation and scrolls horizontally instead of
// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import css from './DiffBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches
* {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a
* long body at the same place.
*/
export const DEFAULT_DIFF_MAX_LINES = 16
/**
* One file's change, in the shape {@link DiffBlock} draws. Structurally the
* render-intent contract's `FileDiff`, redeclared here so this primitive stays
* free of the tool contract (the terminal card's decoupling, applied to diffs).
*/
export interface DiffHunk {
/** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
path: string
/** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
oldText: string | null
/** Content after the change (the added side). */
newText: string
}
export interface DiffBlockProps {
/** One entry per applied hunk, in file order; empty renders nothing. */
diffs: DiffHunk[]
/** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/** A single rendered body line and its role, so the height cap slices a flat list. */
interface DiffRow {
kind: 'path' | 'del' | 'add' | 'gap'
text: string
}
/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
function assertNever(value: never): never {
throw new Error(`unreachable diff row kind: ${String(value)}`)
}
/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
path: css.path,
del: css.del,
add: css.add,
gap: css.gap,
}
/**
* Flatten the hunks into the body's rows plus the footer counts. A path header
* opens each new file; a same-file second hunk (a scattered edit) opens with a
* `⋯` gap instead of repeating the path. Every old-side line counts toward
* `removed` and every new-side line toward `added`. The file count is of
* DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file
* read as `1 file` on both front ends.
* @param diffs - the hunks to render.
* @returns the body rows, the +/- totals, and the distinct-file count.
*/
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
const rows: DiffRow[] = []
const paths = new Set<string>()
let added = 0
let removed = 0
let prevPath: string | undefined
for (const diff of diffs) {
paths.add(diff.path)
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
else rows.push({ kind: 'gap', text: '⋯' })
prevPath = diff.path
if (diff.oldText !== null) {
for (const line of contentLines(diff.oldText)) {
rows.push({ kind: 'del', text: line })
removed++
}
}
for (const line of contentLines(diff.newText)) {
rows.push({ kind: 'add', text: line })
added++
}
}
return { rows, added, removed, files: paths.size }
}
/**
* Split a side's text into its content lines. Empty text is zero lines (a full
* deletion's `newText` or a create's absent `oldText` side draws nothing), and a
* single trailing newline is a line terminator rather than an extra empty line —
* the same terminator rule TerminalBlock applies to command output. An interior
* blank line (a genuine `\n\n`) survives.
* @param text - the removed or added side's text.
* @returns the content lines, without the terminating newline.
*/
function contentLines(text: string): string[] {
if (text === '') return []
const body = text.endsWith('\n') ? text.slice(0, -1) : text
return body.split('\n')
}
/**
* The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
* content, exactly what the card shows. The removed and added blocks are the
* change; the path headers keep a multi-file copy attributable.
* @param rows - the flattened body rows.
* @returns the diff as plain text.
*/
function copyText(rows: DiffRow[]): string {
return rows.map((row) => {
switch (row.kind) {
case 'del': return `- ${row.text}`
case 'add': return `+ ${row.text}`
case 'path': return row.text
case 'gap': return row.text
/* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */
default: return assertNever(row.kind)
}
}).join('\n')
}
/**
* Render a file mutation as an inline diff surface.
* @param props - see {@link DiffBlockProps}.
* @returns the diff block element.
*/
export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(copyText(rows)).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, rows])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
if (rows.length === 0) return null
const hidden = rows.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
// card, so a body's head and tail slices agree across the front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
return (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
<div className={css.body}>
{head.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{tail.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
</div>
<div className={css.footer}> +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
</div>
)
}

View File

@@ -28,6 +28,8 @@ export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'

View File

@@ -0,0 +1,182 @@
// @vitest-environment jsdom
// DiffBlock: the per-file hunk rows (path header, removed block, added block),
// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and
// its singular/plural, the head/tail height cap and its expand control, the
// empty-diffs null render, and the copy control writing the prefixed diff text
// on both the accepted and the refused clipboard paths. writeClipboard's own
// return contract is pinned in terminal-block.spec.tsx (the shared seam), so
// only its DOM consequence is asserted here.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** The rendered body rows, one string per visible line (CSS-module class prefix). */
function bodyRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '')
}
/** Only the changed rows (add/del), excluding the path header and gap chrome. */
function changeRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '')
}
/** `count` numbered added lines as one hunk's newText. */
function added(count: number): string {
return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n')
}
describe('DiffBlock structure', () => {
it('renders a create as a path header and an added block (no removed side)', () => {
const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('notes/new.txt')).toBeTruthy()
// No removed rows: both change lines are added.
expect(changeRows(container)).toEqual(['hello', 'world'])
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2)
})
it('renders an edit as a removed block above an added block', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1)
expect(changeRows(container)).toEqual(['old', 'new'])
})
it('opens a same-file second hunk with a gap instead of repeating the path', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
// One path header, one gap row.
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1)
})
it('opens a new file with its own path header', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'b.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0)
})
it('renders nothing for empty diffs', () => {
const { container } = render(<DiffBlock diffs={[]} />)
expect(container.firstChild).toBeNull()
})
it('treats a trailing newline as a terminator, not an extra blank line', () => {
// A create whose newText ends in a newline is one added line, not two, and
// the footer counts one — the phantom `+ ` empty line the naive split drew.
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
expect(changeRows(container)).toEqual(['hello'])
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('renders a full deletion as removed-only with no phantom added line', () => {
// newText '' is zero added lines: an empty string must contribute nothing.
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
})
it('keeps a genuine interior blank line', () => {
const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
})
})
describe('DiffBlock footer', () => {
it('counts added and removed lines and one file', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
})
it('pluralizes the distinct-file count', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: null, newText: 'x' },
{ path: 'b.ts', oldText: null, newText: 'y' },
]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
})
})
describe('DiffBlock height cap', () => {
it('shows head and tail with an expand control past the cap, then all lines expanded', () => {
// One added line over the default cap forces the collapse.
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }]
// The path header counts as a row, so a body of maxLines added lines plus
// the header is one over the cap.
const { container } = render(<DiffBlock diffs={diffs} />)
const toggle = screen.getByRole('button', { name: /展开其余/ })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
// Collapsed shows fewer rows than the full body.
const collapsedCount = bodyRows(container).length
expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1)
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true')
expect(bodyRows(container).length).toBeGreaterThan(collapsedCount)
})
it('shows no expand control at or under the cap', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }]
render(<DiffBlock diffs={diffs} maxLines={16} />)
expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull()
})
})
describe('DiffBlock copy', () => {
it('copies the prefixed diff text and flips the label on success', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'old', newText: 'new' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
render(<DiffBlock diffs={diffs} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
// Path header, del/add prefixes, and the same-file gap all reach the clipboard.
expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q')
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('keeps the label on a refused clipboard write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('ignores a second click while the copied label is showing', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) })
expect(writeText).toHaveBeenCalledTimes(1)
})
})

View File

@@ -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: c392d745021c0fc6a752cf71dd0506a435106c50
README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: 3e191b501e69062b671df0f237f2128a4ad086d1
README.zh.md: 44ba3eba8bfc756a7d68e43a3d34056349f7eaaa

View File

@@ -2,7 +2,9 @@
English | [中文](README.zh.md)
Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages.
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
## Model Experience
@@ -14,4 +16,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine.
- The General section has no built-in rows; each row appears only when its owning feature plugin is mounted.

View File

@@ -2,7 +2,9 @@
[English](README.md) | 中文
设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)分区(「模型」)仍由各自的功能包提供。
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后无需重新加载即可推进。版本不同时系统会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
## 模型体验
@@ -14,4 +16,4 @@
## 已知限制与暂缓事项
- **「权限」与「工具调用」只是展示骨架**:对应的宿主服务和 RPC 方法尚不存在;这些控件已禁用,不会写入任何内容。一旦获得实际支撑,按照自注册原则,每一项都会移至拥有它的功能插件
- 「通用」分区没有内置行;每一行仅在其所属功能插件挂载时出现

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-general",
"description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries",
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,7 +26,8 @@
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
},
@@ -35,22 +36,30 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -1,7 +1,5 @@
/* General section rows (figma 501:29983 'Options'): stacked groups, 16px
* vertical padding each, hairline separator under all but the last child
* (feature-contributed rows carry their own row chrome and separators; the
* :last-child rule strips the trailing one wherever the column ends). */
/* Feature-contributed rows own their chrome and separators; the section
* strips the trailing separator wherever the column ends. */
.section {
display: flex;
@@ -12,112 +10,3 @@
.section > :last-child {
border-bottom: none;
}
/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */
.group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled {
cursor: default;
}
.chevron {
flex: none;
}
/* Tool Call mode cubes share an 8px gap and wrap to one per row when the
panel is too narrow. */
.cubeRow {
display: flex;
align-items: stretch;
gap: 8px;
flex-wrap: wrap;
}
/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the
* 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10,
* vertical = inner pad 8). */
.modeCube {
box-sizing: border-box;
flex: 1 1 276px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 8px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: transparent;
text-align: left;
cursor: pointer;
}
.modeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
* step has no alias-layer name). */
.selected {
background: var(--dsw-alias-bg-module-platform);
border-color: var(--dsw-static-neutral-bluish-400);
}

View File

@@ -1,54 +1,19 @@
/**
* The General section (figma 501:29983 'Options'): Permission and Tool Call
* skeleton rows, then the feature-contributed preference rows from the
* `settings.general.item` slot (locale → Language, ui-theme → Appearance).
* The section column stacks rows; each row draws its own internals and
* separator.
*/
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
/** The General section: one column rendering feature-owned item contributions. */
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import css from './GeneralSection.module.css'
/** Full component props: section owner share + item render share + the standard locale seat. */
/** Full component props: section owner share plus item render share. */
export type GeneralSectionComponentProps =
PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & PropsLocale<'settings'>
PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'>
/**
* Render the General section content column.
* @param props - composed slot props (contract/slots.ts).
* @returns the section element tree.
*/
export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) {
export function GeneralSection({ renderSlot }: GeneralSectionComponentProps) {
return (
<div className={css.section}>
{/* Permission (skeleton): disabled selector pill. */}
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('permission.title')}</div>
<div className={css.desc}>{t('permission.desc')}</div>
</div>
<button type="button" className={css.selector} disabled>
{t('permission.value')}
<IconChevronDownOutline14 className={css.chevron} />
</button>
</div>
{/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */}
<div className={css.group}>
<div className={css.title}>{t('toolcall.title')}</div>
<div className={css.cubeRow}>
<div className={`${css.modeCube} ${css.selected}`}>
<div className={css.title}>{t('toolcall.schema.title')}</div>
<div className={css.desc}>{t('toolcall.schema.desc')}</div>
</div>
<div className={css.modeCube}>
<div className={css.title}>{t('toolcall.code.title')}</div>
<div className={css.desc}>{t('toolcall.code.desc')}</div>
</div>
</div>
</div>
{/* Feature-owned preference rows (Language, Appearance, …). */}
{renderSlot('settings.general.item', {})}
</div>
)

View File

@@ -0,0 +1,164 @@
.page {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 64px));
max-height: 100vh;
padding: clamp(64px, 9vh, 104px) 0 40px;
box-sizing: border-box;
overflow-y: auto;
color: var(--dsw-alias-label-primary);
--welcome-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
.brand {
display: flex;
align-items: center;
margin-bottom: 42px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
outline: none;
}
.opening,
.status,
.reflection,
.feedback,
.error {
margin: 0;
}
.opening {
margin-top: 30px;
}
.status {
margin-top: 18px;
}
.reflection {
margin-top: 36px;
padding: 0;
}
.feedback {
margin-top: 30px;
}
.opening,
.status,
.reflection,
.feedback {
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
.feedback strong {
color: inherit;
font-weight: 500;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: 32px;
}
.error {
margin-top: 20px;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-state-error-primary);
}
.primary {
min-width: 120px;
transition: transform 140ms var(--welcome-ease-out);
}
.primary:active:not(:disabled) {
transform: scale(0.97);
}
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {
animation: welcome-enter 280ms var(--welcome-ease-out) both;
}
.title { animation-delay: 40ms; }
.opening { animation-delay: 80ms; }
.status { animation-delay: 120ms; }
.reflection { animation-delay: 160ms; }
.feedback { animation-delay: 200ms; }
.footer { animation-delay: 240ms; }
@keyframes welcome-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {
animation: none;
}
.primary {
transition: none;
}
}
@media (max-width: 560px) {
.page {
width: calc(100vw - 40px);
padding-top: 38px;
}
.brand {
margin-bottom: 30px;
}
.opening {
margin-top: 24px;
}
.reflection {
margin-top: 28px;
}
.feedback {
margin-top: 28px;
}
.footer {
margin-top: 30px;
}
.primary {
width: 100%;
}
}

View File

@@ -0,0 +1,87 @@
/** Product-wide, versioned first-run welcome step. */
import { useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
import css from './WelcomeNotice.module.css'
function emphasizedFeedback(paragraph: string, emphasis: string): ReactNode {
const index = paragraph.indexOf(emphasis)
/* v8 ignore next -- both locale values derive from one owner object that contains the emphasis */
if (index < 0) return paragraph
return (
<>
{paragraph.slice(0, index)}
<strong>{emphasis}</strong>
{paragraph.slice(index + emphasis.length)}
</>
)
}
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
export interface WelcomeNoticeInjected {
controller: WelcomeNoticeStore
useSnapshot: SnapshotSelectorHook<WelcomeNoticeState>
}
/** Coordinator owner props plus the welcome step's injected face. */
export type WelcomeNoticeProps =
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
/** Render the mandatory notice until its current version commits durably. */
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
const { complete, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const finished = useRef(false)
const titleRef = useRef<HTMLHeadingElement | null>(null)
const finish = useCallback((): void => {
if (finished.current) return
finished.current = true
complete()
}, [complete])
useEffect(() => {
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
useEffect(() => {
if (state.acknowledged) finish()
}, [finish, state.acknowledged])
useEffect(() => {
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
}, [state.acknowledged, state.status])
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
const acknowledge = async (): Promise<void> => {
if (await controller.acknowledge()) finish()
}
return (
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
<p className={css.status}>{t('welcome.paragraph.1')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.2')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.3'), t('welcome.feedbackEmphasis'))}
</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>
<Button
variant="primary"
className={css.primary}
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
)
}

View File

@@ -1,25 +1,34 @@
/**
* Settings ownerless-copy plugin, browser half: registers everything on the
* Settings surface that belongs to no single feature — the trigger/header
* chrome content, the General section (skeleton rows + the
* `settings.general.item` slot declaration), and the `settings`
* dictionaries. Feature-owned rows and sections stay with their features.
* chrome content, the General section, and the `settings` dictionaries.
* Feature-owned rows and sections stay with their features.
* Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { WelcomeNotice } from './WelcomeNotice.tsx'
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
import { en, zh, type SettingsKey } from './locales.ts'
export type {
CloseLabelProps, HeaderContentProps, TriggerContentProps,
} from './chrome.tsx'
export type { GeneralSectionComponentProps } from './GeneralSection.tsx'
export type {
GeneralSectionComponentProps,
} from './GeneralSection.tsx'
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
export type { WelcomeNoticeState } from './welcome-store.ts'
export type { SettingsKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -37,7 +46,7 @@ const NS = 'settings'
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
*/
export const inject = ['slots', 'locale']
export const inject = ['slots', 'locale', 'connection']
/**
* Register the `settings` dictionaries, the chrome content, and the General
@@ -51,6 +60,25 @@ export function apply(ctx: ClientContext): void {
// seat, and the nav label is a thunk the owner resolves per render — no
// locale/change re-registration wiring.
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const welcomeController = new WelcomeNoticeStore(connection.api)
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const welcomeInjected = (): WelcomeNoticeInjected => ({
controller: welcomeController,
useSnapshot: useWelcomeSnapshot,
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
refreshWelcomeIfLoaded(welcomeController)
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: welcome invalidations')
ctx.effect(() => {
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
@@ -67,11 +95,20 @@ export function apply(ctx: ClientContext): void {
locale: NS,
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
}, GeneralSection))
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
ctx.slots.register({
name: 'settings.onboarding',
id: 'welcome-notice',
order: -100,
locale: NS,
inject: welcomeInjected,
}, WelcomeNotice))
return () => {
trigger.dispose()
header.dispose()
close.dispose()
general.dispose()
welcome.dispose()
}
}, 'ui-settings-general: chrome and section registrations')
}, 'ui-settings-general: chrome, section, and onboarding registrations')
}

View File

@@ -1,28 +1,20 @@
/**
* `settings` namespace dictionaries: shell chrome plus the shell-owned
* General section (nav label, skeleton rows). Skeleton-row technical copy
* (Read only / Schema mode / Code mode and their descriptions) is shared
* verbatim across locales per the Figma design. Feature-owned rows
* (Language, Appearance) ship their copy in their own packages.
*/
const SHARED = {
'permission.value': 'Read only',
'toolcall.schema.title': 'Schema mode',
'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time',
'toolcall.code.title': 'Code mode',
'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration',
} satisfies Record<string, string>
/** Shell chrome, General-nav, and welcome-notice dictionaries; feature rows own their copy. */
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
...SHARED,
'trigger': '设置',
'title': '设置',
'close': '关闭',
'general.nav': '通用设置',
'permission.title': '权限',
'permission.desc': '选择默认权限模式',
'toolcall.title': '工具调用',
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
'welcome.error': '暂时无法保存确认状态,请重试。',
} satisfies Record<string, string>
/** The settings namespace key union. */
@@ -30,12 +22,16 @@ export type SettingsKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
...SHARED,
'trigger': 'Settings',
'title': 'Settings',
'close': 'Close',
'general.nav': 'General',
'permission.title': 'Permission',
'permission.desc': 'Choose default permission mode',
'toolcall.title': 'Tool Call',
'welcome.title': WELCOME_NOTICE_COPY.en.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
} satisfies Record<SettingsKey, string>

View File

@@ -0,0 +1,108 @@
/** Durable welcome-notice state over the Host settings document. */
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '../onboarding-copy.ts'
/** State rendered by the welcome step. */
export interface WelcomeNoticeState {
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
acknowledged: boolean
error: string | null
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
if (typeof view.value !== 'object' || view.value === null) return undefined
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
return typeof value === 'string' ? value : undefined
}
/** Coordinates welcome acknowledgement reads and the sole durable write. */
export class WelcomeNoticeStore {
/** uSES-safe state source shared by the registered welcome step. */
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
status: 'idle', acknowledged: false, error: null,
})
private generation = 0
/** @param api - settings wire face used for durable reads and writes. */
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/** Load the current acknowledgement from the Host settings document. */
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((state) => { state.status = 'loading'; state.error = null })
try {
const response = await this.api.settings.describe({})
if (!response.result.ok) throw new Error(response.result.error.message)
const view = response.result.value.namespaces.find(
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
)
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
if (generation !== this.generation) return
this.store.update((state) => {
state.status = 'ready'
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
state.error = null
})
} catch (error) {
if (generation !== this.generation) return
this.store.update((state) => {
state.status = 'error'
state.acknowledged = false
state.error = messageOf(error)
})
}
}
/**
* Persist this copy version. The path mutation is idempotent across tabs and
* preserves every sibling setting; failure leaves the step unacknowledged.
* @returns true only when the Host committed the acknowledgement.
*/
async acknowledge(): Promise<boolean> {
const generation = ++this.generation
this.store.update((state) => { state.status = 'saving'; state.error = null })
try {
const response = await this.api.settings.mutate({
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
})
if (!response.result.ok) throw new Error(response.result.error.message)
if (generation === this.generation) {
this.store.update((state) => {
state.status = 'ready'
state.acknowledged = true
state.error = null
})
}
return true
} catch (error) {
if (generation === this.generation) {
this.store.update((state) => {
state.status = 'error'
state.acknowledged = false
state.error = messageOf(error)
})
}
return false
}
}
}
/**
* Refresh only after the welcome step has begun reading durable state.
* @param controller - welcome state owner whose current status decides whether to load.
*/
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
if (controller.store.getSnapshot().status === 'idle') return
void controller.load()
}

View File

@@ -1,4 +1,31 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the general settings plugin. */
export function apply(): void {}
import type { Context } from 'cordis'
import z from 'schemastery'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
} from './onboarding-copy.ts'
export {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from './onboarding-copy.ts'
interface OnboardingSettings {
welcomeNoticeVersion?: string
}
const OnboardingSettingsSchema: z<OnboardingSettings> = z.object({
[WELCOME_NOTICE_ACK_FIELD]: z.string(),
})
/** Register the durable GUI-onboarding section when a settings provider exists. */
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
OnboardingSettingsSchema,
)
})
}

View File

@@ -15,10 +15,9 @@ export const name = 'client-ui-settings-general-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a copy-owning registrant contributing chrome content
* and the General section into shell-declared slots — it emits no cordis
* events and owns no cross-plugin mutable relation; slot conflicts already
* fail loud in the slot core at load time.
* No runtime invariant: the settings seam validates and publishes the durable
* welcome section, while slot conflicts fail loud in the slot core; this
* package owns no additional event/data relationship between those systems.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,37 @@
/** Durable settings namespace for product-wide GUI onboarding facts. */
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
/** Field storing the last welcome notice version the user acknowledged. */
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
/**
* Bump only when the notice changes materially and every user should see it
* again. The acknowledgement is compared for exact equality.
*/
export const WELCOME_NOTICE_VERSION = '2026-07-30.5'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
zh: {
title: '内测声明',
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。',
'目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
},
en: {
title: 'Internal Testing Notice',
paragraphs: [
'Thank you for taking the time to try DeepSeek Harness.',
'This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.',
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our existing designs.',
'We especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in the company WeChat group. Every piece of feedback helps us refine it.',
],
feedbackEmphasis: 'If you have any feedback or suggestions, please leave us a message in the company WeChat group',
continueLabel: 'Continue',
},
} as const

View File

@@ -1,19 +1,23 @@
/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
/** The four seats this plugin fills (slot name → expected component). */
/** The five seats this plugin fills (slot name → expected component). */
const SEATS = [
['settings.trigger', TriggerContent],
['settings.header', HeaderContent],
['settings.close', CloseLabel],
['settings.section', GeneralSection],
['settings.onboarding', WelcomeNotice],
] as const
async function bench() {
@@ -21,7 +25,25 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
result: {
ok: true as const,
value: {
writable: true,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: {},
applies: 'live' as const,
secrets: [],
revision: 0,
}],
},
},
}))
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
}
/** Declare the shell's four child slots the way ui-settings' entry does. */
@@ -34,6 +56,7 @@ function declare(slots: SlotsService): () => void {
'settings.header': { kind: 'single', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
} as never,
() => null,
@@ -46,10 +69,10 @@ function generalEntry(slots: SlotsService) {
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('fills all four seats for declarations before or after apply', async () => {
it('fills all five seats for declarations before or after apply', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
@@ -61,11 +84,13 @@ describe('ui-settings-general apply', () => {
// The nav label is a locale-following thunk; owners resolve at read time.
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
expect(before.slots.entries('settings.general.item')).toEqual([])
const welcome = before.slots.entries('settings.onboarding')[0]!
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
// Copy rides the standard locale seat: every seat declares the namespace.
for (const [name] of SEATS) {
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
}
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0)
@@ -76,6 +101,9 @@ describe('ui-settings-general apply', () => {
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries(name)).toHaveLength(1)
}
await vi.waitFor(() => {
expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
})
})
it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => {
@@ -110,6 +138,22 @@ describe('ui-settings-general apply', () => {
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
})
it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')[0]!
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', 'unrelated')
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)
@@ -124,6 +168,7 @@ describe('ui-settings-general apply', () => {
for (const [name, component] of SEATS) {
expect(b.slots.entries(name)[0]!.component).toBe(component)
}
expect(b.slots.entries('settings.general.item')).toEqual([])
expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
// The recovered registrations still ride the locale path.
b.locale.setLocale('en')

View File

@@ -4,13 +4,14 @@ import { cleanup, render, screen } from '@testing-library/react'
import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import type { TriggerContentProps } from '../src/client/chrome.tsx'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
// The seat's key domain is settings common; the stub answers from the
// package dictionary and falls back to the key like the real chain.
const t: GeneralSectionComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
const t: TriggerContentProps['t'] = key => (en as Record<string, string>)[key] ?? key
// Global standard kit stubs: none of these components consume the hooks.
const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never
@@ -42,31 +43,12 @@ describe('GeneralSection', () => {
const renderSlot = vi.fn(
((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
)
const props: GeneralSectionComponentProps = { ...kit, t, renderSlot }
const props: GeneralSectionComponentProps = { ...kit, renderSlot }
const view = render(<GeneralSection {...props} />)
return { view, renderSlot }
}
it('renders the Permission skeleton row with the disabled selector', () => {
mount()
expect(screen.getByText('Permission')).toBeTruthy()
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
const selector = screen.getByRole<HTMLButtonElement>('button', { name: /Read only/ })
expect(selector.disabled).toBe(true)
})
it('renders the Tool Call skeleton cubes with schema pinned selected', () => {
mount()
expect(screen.getByText('Tool Call')).toBeTruthy()
const schema = screen.getByText('Schema mode')
const code = screen.getByText('Code mode')
expect(schema.parentElement!.className).toContain('selected')
expect(code.parentElement!.className).not.toContain('selected')
expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy()
expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy()
})
it('renders the feature-contributed item slot after the skeleton rows', () => {
it('renders the item slot as the section body', () => {
const { renderSlot } = mount()
expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {})
expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()

View File

@@ -0,0 +1,29 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply } from '../src/index.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
class MemorySettings extends Settings {
readonly writable = true
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
return Promise.resolve()
}
}
describe('ui-settings-general host', () => {
it('registers and disposes the durable onboarding namespace with its fiber', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
const fiber = ctx.plugin({ apply })
await fiber.await()
expect(ctx.settings.describe().map(row => row.ns)).toContain(
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
)
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
)
})
})

View File

@@ -9,10 +9,4 @@ describe('invariant companion', () => {
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

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