Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/ui-conversation/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
@@ -1021,6 +1021,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
|
||||
@@ -1722,7 +1725,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 ?? ''}`
|
||||
@@ -1808,6 +1814,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
|
||||
@@ -2189,6 +2205,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)
|
||||
|
||||
@@ -125,7 +125,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,
|
||||
@@ -137,6 +137,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: b7e4bc17bd074310df33609c1bc127a187d46d93
|
||||
README.zh.md: ae4df903ed7cb85c81c300e40db3e943dbf01f07
|
||||
README.md: f327937584cbffdd871632f22fef40aaa76d4be4
|
||||
README.zh.md: 44aa85168fb5a81db2d2438258d2dae9509b7b40
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -143,7 +143,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 }))
|
||||
|
||||
@@ -156,13 +159,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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -73,6 +73,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
export function workspaceListState(): WorkspaceListState {
|
||||
return {
|
||||
items: [],
|
||||
archivedSessionIds: [],
|
||||
state: 'idle',
|
||||
phase: 'ready',
|
||||
error: null,
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,8 +552,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'))
|
||||
@@ -561,12 +565,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 254a38de5fa7c154ddd002cadecbfa4093db0ed2
|
||||
README.zh.md: 1c5a7536f3b9ea1947b139dc517a04aabe6af79c
|
||||
README.md: b87d562d9faed9beeb7881f1cf037ce75a046dde
|
||||
README.zh.md: c2dbec5987fef987d5d89627259a1769ee0a29f3
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -205,36 +205,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>
|
||||
)
|
||||
}
|
||||
@@ -496,7 +471,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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
@@ -261,16 +261,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({
|
||||
@@ -284,14 +284,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', () => {
|
||||
@@ -455,6 +456,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', () => {
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('DetailsPanel diff 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(
|
||||
|
||||
@@ -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(
|
||||
@@ -117,7 +117,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(
|
||||
|
||||
@@ -102,7 +102,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) =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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') })}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = () => (
|
||||
|
||||
@@ -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: 4edb34ccbe8f628c04e410a6dd2f002e247623f3
|
||||
README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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 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, 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.
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[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 页仍是诊断界面。
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: '保存中…',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
|
||||
|
||||
13
packages/client/ui-models/tests/styles.spec.ts
Normal file
13
packages/client/ui-models/tests/styles.spec.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-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>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-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` 切换当前会话,但不会获得这项浏览器贡献。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
133
packages/client/ui-permission/src/client/PermissionRow.tsx
Normal file
133
packages/client/ui-permission/src/client/PermissionRow.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
51
packages/client/ui-permission/src/client/locales.ts
Normal file
51
packages/client/ui-permission/src/client/locales.ts
Normal 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>
|
||||
22
packages/client/ui-permission/src/client/presentation.ts
Normal file
22
packages/client/ui-permission/src/client/presentation.ts
Normal 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)
|
||||
}
|
||||
191
packages/client/ui-permission/src/client/settings-store.ts
Normal file
191
packages/client/ui-permission/src/client/settings-store.ts
Normal 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()
|
||||
}
|
||||
4
packages/client/ui-permission/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-permission/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
157
packages/client/ui-permission/tests/permission-row.spec.tsx
Normal file
157
packages/client/ui-permission/tests/permission-row.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
254
packages/client/ui-permission/tests/settings-store.spec.ts
Normal file
254
packages/client/ui-permission/tests/settings-store.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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: 241678567c4dbc7411ab9e76f595f2f696cc02d6
|
||||
README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 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, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -14,4 +14,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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。
|
||||
设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **「权限」与「工具调用」只是展示骨架**:对应的宿主服务和 RPC 方法尚不存在;这些控件已禁用,不会写入任何内容。一旦获得实际支撑,按照自注册原则,每一项都会移至拥有它的功能插件。
|
||||
- 「通用」分区没有内置行;每一行仅在其所属功能插件挂载时出现。
|
||||
|
||||
@@ -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 plugin: the General section, shell trigger/header chrome content, and settings dictionaries",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* 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'
|
||||
@@ -19,7 +18,9 @@ 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 { SettingsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
/**
|
||||
* `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 and General-nav dictionaries; feature rows own their copy. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
...SHARED,
|
||||
'trigger': '设置',
|
||||
'title': '设置',
|
||||
'close': '关闭',
|
||||
'general.nav': '通用设置',
|
||||
'permission.title': '权限',
|
||||
'permission.desc': '选择默认权限模式',
|
||||
'toolcall.title': '工具调用',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings namespace key union. */
|
||||
@@ -30,12 +13,8 @@ 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',
|
||||
} satisfies Record<SettingsKey, string>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 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'
|
||||
@@ -61,11 +61,11 @@ 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([])
|
||||
// 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 +76,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 () => {
|
||||
@@ -124,6 +127,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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -27,7 +27,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)
|
||||
|
||||
@@ -113,7 +113,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47
|
||||
README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606
|
||||
README.md: cc73214a281c6950acf8846f0bae3214c8726934
|
||||
README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534
|
||||
|
||||
@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
|
||||
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
|
||||
|
||||
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
|
||||
|
||||
@@ -23,5 +23,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
|
||||
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions.
|
||||
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。
|
||||
|
||||
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
|
||||
|
||||
@@ -23,5 +23,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
|
||||
- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
|
||||
@@ -102,18 +102,22 @@ type SessionTreeProps = Pick<
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Registry-global archive set (hidden rows). */
|
||||
archivedSessionIds: readonly SessionNode['id'][]
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned session rename dialog. */
|
||||
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
||||
/** Archive a session (row menu action; the row disappears on the state echo). */
|
||||
onSessionArchive: (sessionId: SessionNode['id']) => void
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, forkSession, workspaces,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
|
||||
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
@@ -129,8 +133,8 @@ function SessionTree({
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects }),
|
||||
[list, workspaces, expandedProjects],
|
||||
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
|
||||
[list, workspaces, archivedSessionIds, expandedProjects],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -209,6 +213,7 @@ function SessionTree({
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
drag={dragProps}
|
||||
t={t}
|
||||
/>
|
||||
@@ -223,9 +228,11 @@ function SessionTree({
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
|
||||
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
|
||||
>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list), [list])
|
||||
const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
|
||||
const now = Date.now()
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
@@ -242,6 +249,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<S
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -263,12 +271,14 @@ function SearchResults({
|
||||
useSessions,
|
||||
open,
|
||||
workspaces,
|
||||
archivedSessionIds,
|
||||
query,
|
||||
remote,
|
||||
resultLimit,
|
||||
t,
|
||||
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
archivedSessionIds: readonly SessionNode['id'][]
|
||||
query: string
|
||||
remote: RemoteSearchState
|
||||
resultLimit: number
|
||||
@@ -278,8 +288,8 @@ function SearchResults({
|
||||
? remote
|
||||
: { query, status: 'loading' as const, items: [], hasMore: false }
|
||||
const results = useMemo(
|
||||
() => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit),
|
||||
[list, workspaces, query, currentRemote, resultLimit],
|
||||
() => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit),
|
||||
[list, workspaces, query, archivedSessionIds, currentRemote, resultLimit],
|
||||
)
|
||||
const pending = currentRemote.status === 'loading'
|
||||
const failed = currentRemote.status === 'error'
|
||||
@@ -337,6 +347,7 @@ export function WorkspaceBrowser({
|
||||
forkSession,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
archiveSession,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
searchSessions,
|
||||
@@ -346,6 +357,7 @@ export function WorkspaceBrowser({
|
||||
t,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
@@ -475,6 +487,16 @@ export function WorkspaceBrowser({
|
||||
setSessionRenameError(null)
|
||||
}
|
||||
|
||||
// Archive is dialog-free: not destructive (the log and the accounting slot
|
||||
// remain), so the menu action commits directly; the row disappears when the
|
||||
// archive-set echo lands. Failures are non-fatal console diagnostics, the
|
||||
// same posture as reorder rejections.
|
||||
const onSessionArchive = (sessionId: SessionNode['id']) => {
|
||||
archiveSession(sessionId).catch((reason: unknown) => {
|
||||
console.warn('session archive rejected:', reason)
|
||||
})
|
||||
}
|
||||
|
||||
// Delete dialog is separate from the row so a successful removal can
|
||||
// unmount that row without tearing down the in-flight confirmation state.
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
|
||||
@@ -597,6 +619,7 @@ export function WorkspaceBrowser({
|
||||
useSessions={useSessions}
|
||||
open={open}
|
||||
workspaces={workspaces}
|
||||
archivedSessionIds={archivedSessionIds}
|
||||
query={normalizedQuery}
|
||||
remote={remoteSearch}
|
||||
resultLimit={searchResultLimit}
|
||||
@@ -607,15 +630,18 @@ export function WorkspaceBrowser({
|
||||
? (
|
||||
<FlatList
|
||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||
onSessionRename={onSessionRename} t={t}
|
||||
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
|
||||
archivedSessionIds={archivedSessionIds} t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
onSessionArchive={onSessionArchive}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
archivedSessionIds={archivedSessionIds}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
|
||||
@@ -113,6 +113,12 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Archive a Session into the registry-global set: hidden from grouping
|
||||
* surfaces, log and accounting slot retained. Archiving the current
|
||||
* session clears the selection into the New Session view state.
|
||||
*/
|
||||
archiveSession: (sessionId: SessionId) => Promise<void>
|
||||
/**
|
||||
* Reorder a session inside its Workspace account (DOM-insertBefore
|
||||
* semantics: omitted anchor appends to the end). The view refreshes from
|
||||
|
||||
@@ -92,6 +92,7 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ export const zh = {
|
||||
'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。',
|
||||
'delete.pending': '正在删除工作区…',
|
||||
'menu.fork': '分叉会话',
|
||||
'menu.deleteSession': '删除会话',
|
||||
'menu.archiveSession': '归档会话',
|
||||
'sessions.count.one': '{n} 个会话',
|
||||
'sessions.count.other': '{n} 个会话',
|
||||
'actions.workspace.aria': '工作区“{name}”的操作',
|
||||
@@ -108,7 +108,7 @@ export const en = {
|
||||
'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.',
|
||||
'delete.pending': 'Deleting workspace…',
|
||||
'menu.fork': 'Fork session',
|
||||
'menu.deleteSession': 'Delete session',
|
||||
'menu.archiveSession': 'Archive session',
|
||||
'sessions.count.one': '{n} session',
|
||||
'sessions.count.other': '{n} sessions',
|
||||
'actions.workspace.aria': 'Workspace actions for {name}',
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||
* except workspace Rename/Delete and session Rename/Fork; the session and
|
||||
* workspace hover cards are suppressed while a menu is open.
|
||||
* except workspace Rename/Delete and session Rename/Fork/Archive; the session
|
||||
* and workspace hover cards are suppressed while a menu is open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
|
||||
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16,
|
||||
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
|
||||
@@ -175,7 +175,9 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number;
|
||||
return (
|
||||
<div className={css.hoverContent}>
|
||||
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
|
||||
<div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>
|
||||
{/* Same placeholder rule as the row's trailing cell: no timestamp
|
||||
before the first prompt. */}
|
||||
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
|
||||
<div className={css.hoverStatus}>
|
||||
<StateDot state={node.running ? 'ongoing' : 'done'} />
|
||||
<span>{node.running ? t('status.running') : t('status.idle')}</span>
|
||||
@@ -243,7 +245,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: {
|
||||
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
|
||||
node: SessionNode
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
@@ -252,6 +254,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
||||
/** Fork a session at its last completed turn (row menu action). */
|
||||
onFork: (id: SessionNode['id']) => void
|
||||
/** Archive this session (row menu action; commits without a dialog). */
|
||||
onArchive: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group sessions outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
t: RowTranslate
|
||||
@@ -260,10 +264,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
const title = displayTitle(node, t)
|
||||
const selected = node.id === currentId
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Archive replaces the former Delete placeholder: it hides the row through
|
||||
// the registry-global archive set and never touches the session log, so it
|
||||
// is not styled as destructive and needs no confirmation dialog.
|
||||
const sessionMenuItems = [
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
|
||||
{ id: 'fork', label: t('menu.fork'), icon: <IconBranchOutline16 /> },
|
||||
{ id: 'delete', label: t('menu.deleteSession'), icon: <IconTrashOutline16 />, danger: true },
|
||||
{ id: 'archive', label: t('menu.archiveSession'), icon: <IconDownloadOutline16 /> },
|
||||
]
|
||||
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
|
||||
const ownRow = (
|
||||
@@ -301,31 +308,38 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
>
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.title}>{title}</span>
|
||||
<span className={css.time}>{timeLabel(row.updatedAt, now, t)}</span>
|
||||
<span className={css.rowActions}>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={sessionMenuItems}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title)
|
||||
if (id === 'fork') onFork(node.id) // delete stays visual-only.
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('actions.session.aria', { name: title })}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
|
||||
>
|
||||
<IconEllipsisOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{/* A blank New Session row is a provisional placeholder: nothing has
|
||||
happened in it yet, so a "now" timestamp and the row verbs
|
||||
(rename/fork/archive) would all act on content that does not
|
||||
exist — both trailing cells stay off until the first prompt. */}
|
||||
{!row.blank && <span className={css.time}>{timeLabel(row.updatedAt, now, t)}</span>}
|
||||
{!row.blank && (
|
||||
<span className={css.rowActions}>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={sessionMenuItems}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title)
|
||||
if (id === 'fork') onFork(node.id)
|
||||
if (id === 'archive') onArchive(node.id)
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('actions.session.aria', { name: title })}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
|
||||
>
|
||||
<IconEllipsisOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */
|
||||
function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean {
|
||||
return !session.blank || session.id === current
|
||||
/**
|
||||
* Ordinary sessions are visible; among blank sessions, only the current one
|
||||
* is visible; archived sessions are visible nowhere (their accounting slots
|
||||
* remain, so unarchiving restores position).
|
||||
*/
|
||||
function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet<SessionId>): boolean {
|
||||
return !archived.has(session.id) && (!session.blank || session.id === current)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +130,11 @@ function buildGroup(
|
||||
* order, with members resolved from sessionIds in their stored order. Sessions
|
||||
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
|
||||
*/
|
||||
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
|
||||
function groupByWorkspace(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
archived: ReadonlySet<SessionId>,
|
||||
): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
for (const workspace of workspaces) {
|
||||
@@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
|
||||
const summary = list.byId[id]
|
||||
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
|
||||
accounted.add(id)
|
||||
if (!sessionVisible(summary, list.current)) continue
|
||||
if (!sessionVisible(summary, list.current, archived)) continue
|
||||
members.push(summary)
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
@@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
|
||||
const stray = list.ids
|
||||
.map(id => list.byId[id])
|
||||
.filter((s): s is SessionSummary =>
|
||||
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
|
||||
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
}
|
||||
@@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode {
|
||||
*
|
||||
* Every group shows; sessions populate under expanded groups, preserving
|
||||
* Host account order. Blank sessions are excluded except for the selected
|
||||
* provisional New Session row. Content search lives outside this derivation
|
||||
* provisional New Session row; archived sessions are excluded everywhere.
|
||||
* Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param archivedSessionIds - registry-global archive set.
|
||||
* @param view - local expansion arrays.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
archivedSessionIds: readonly SessionId[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const archived = new Set(archivedSessionIds)
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces)) {
|
||||
for (const g of groupByWorkspace(list, workspaces, archived)) {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
@@ -209,13 +221,15 @@ export function deriveGroups(
|
||||
* no parent/child adjacency. Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot.
|
||||
* @param archivedSessionIds - registry-global archive set.
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState): SessionNode[] {
|
||||
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
|
||||
const archived = new Set(archivedSessionIds)
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined || !sessionVisible(s, list.current)) continue
|
||||
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
@@ -238,6 +252,7 @@ export interface RelativeTime {
|
||||
* @param list - session metadata authority.
|
||||
* @param workspaces - Workspace membership and display labels.
|
||||
* @param query - caller text; surrounding whitespace is ignored.
|
||||
* @param archivedSessionIds - registry-global archive set (members never match).
|
||||
* @param content - ranked Host content-search page.
|
||||
* @param limit - protocol-owned maximum merged row count.
|
||||
* @returns bounded deduplicated flat rows and a refine-query hint bit.
|
||||
@@ -246,11 +261,13 @@ export function deriveSearchResults(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
query: string,
|
||||
archivedSessionIds: readonly SessionId[],
|
||||
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
|
||||
limit: number,
|
||||
): SearchResultSet {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q === '') return { items: [], hasMore: false }
|
||||
const archived = new Set(archivedSessionIds)
|
||||
|
||||
const workspaceBySession = new Map<SessionId, string>()
|
||||
for (const workspace of workspaces) {
|
||||
@@ -270,7 +287,7 @@ export function deriveSearchResults(
|
||||
const summary = list.byId[id]
|
||||
// Blank placeholders never match a query (their canonical title displays
|
||||
// localized, so matching it would tie search to one language).
|
||||
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
|
||||
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue
|
||||
if (
|
||||
sessionTitle(summary).toLowerCase().includes(q)
|
||||
|| labelOf(summary).toLowerCase().includes(q)
|
||||
@@ -290,7 +307,7 @@ export function deriveSearchResults(
|
||||
for (const summary of local) include(summary)
|
||||
for (const item of content.items) {
|
||||
const summary = list.byId[item.sessionId]
|
||||
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
|
||||
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('workspace browser rows', () => {
|
||||
const onOpen = vi.fn()
|
||||
render(
|
||||
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
|
||||
)
|
||||
|
||||
const row = screen.getByRole('treeitem')
|
||||
@@ -157,18 +157,43 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and dispatches rename and fork', () => {
|
||||
it('blank New Session rows carry no menu, no time label, and no hover-card time', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
// The placeholder has no content yet: no row verbs, no "now" stamp.
|
||||
expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull()
|
||||
expect(screen.queryByText('刚刚')).toBeNull()
|
||||
// The hover card keeps title + status but drops the timestamp line.
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.getByText('空闲')).toBeTruthy()
|
||||
expect(screen.queryByText('刚刚')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const onFork = vi.fn()
|
||||
const onArchive = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} t={t} />)
|
||||
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/)
|
||||
// Archive is not destructive (log and accounting slot remain): no danger styling.
|
||||
expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
|
||||
// Rename dispatches with the current display title (dialog prefill).
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -177,10 +202,12 @@ describe('workspace browser rows', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
|
||||
expect(onFork).toHaveBeenCalledWith(node.id)
|
||||
// Delete stays visual-only.
|
||||
// Archive dispatches without opening the session.
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
|
||||
expect(onArchive).toHaveBeenCalledWith(node.id)
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
@@ -194,7 +221,7 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -220,7 +247,7 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('空闲')).toBeTruthy()
|
||||
@@ -237,7 +264,7 @@ describe('workspace browser rows', () => {
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -255,7 +282,7 @@ describe('workspace browser rows', () => {
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={active} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -269,7 +296,7 @@ describe('workspace browser rows', () => {
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={after} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -26,19 +26,21 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
|
||||
const view = (expandedProjects: readonly string[] = []) => ({
|
||||
expandedProjects,
|
||||
})
|
||||
const noArchive: readonly SessionId[] = []
|
||||
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
|
||||
const sessions = list(summary('newer', 20), summary('older', 10))
|
||||
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
|
||||
const groups = deriveGroups(sessions, workspaces, view(['first']))
|
||||
const groups = deriveGroups(sessions, workspaces, noArchive, view(['first']))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
|
||||
})
|
||||
|
||||
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
|
||||
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
|
||||
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
|
||||
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
|
||||
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
|
||||
})
|
||||
@@ -52,7 +54,7 @@ describe('deriveGroups', () => {
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']),
|
||||
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
|
||||
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
|
||||
@@ -63,7 +65,7 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
|
||||
expect(groups[0]!.sessionCount).toBe(2)
|
||||
// A non-current blank stray never surfaces an Ungrouped bucket either.
|
||||
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view())
|
||||
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view())
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
@@ -80,6 +82,7 @@ describe('deriveGroups', () => {
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
noArchive,
|
||||
{ expandedProjects: [UNGROUPED_KEY] },
|
||||
)
|
||||
|
||||
@@ -90,7 +93,7 @@ describe('deriveGroups', () => {
|
||||
])
|
||||
|
||||
// Equal timestamps use ids as a deterministic tiebreak in either input order.
|
||||
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
|
||||
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]!
|
||||
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
|
||||
})
|
||||
|
||||
@@ -100,17 +103,32 @@ describe('deriveGroups', () => {
|
||||
ids: [sid('present')],
|
||||
byId: { [sid('present')]: summary('present', 1) },
|
||||
}
|
||||
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
|
||||
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project']))
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('hides archived sessions from workspace groups and Ungrouped', () => {
|
||||
const kept = summary('kept', 1, '/projects/first')
|
||||
const gone = summary('gone', 2, '/projects/first')
|
||||
const looseGone = summary('loose-gone', 3, '/other')
|
||||
const sessions = list(kept, gone, looseGone)
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]),
|
||||
)
|
||||
// The archived member drops from its group AND the archived stray never
|
||||
// surfaces an Ungrouped bucket; counts follow the visible rows.
|
||||
expect(groups.map(group => group.key)).toEqual(['first'])
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id])
|
||||
expect(groups[0]!.sessionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
const ws = workspace('project', ['owned'])
|
||||
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
|
||||
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view())
|
||||
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
|
||||
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
|
||||
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view())
|
||||
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -121,13 +139,13 @@ describe('deriveFlat', () => {
|
||||
const child = { ...summary('child', 30), parentId: parent.id }
|
||||
const tieB = summary('tie-b', 20)
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA))
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive)
|
||||
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
|
||||
})
|
||||
|
||||
it('tolerates ids whose summary has not landed yet', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
|
||||
expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('shows only the current blank session and excludes blanks from search', () => {
|
||||
@@ -137,11 +155,35 @@ describe('deriveFlat', () => {
|
||||
...list(summary('real', 1), currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const rows = deriveFlat(sessions)
|
||||
const rows = deriveFlat(sessions, noArchive)
|
||||
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
|
||||
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
|
||||
expect(rows.map(row => row.blank)).toEqual([true, false])
|
||||
})
|
||||
|
||||
it('hides archived sessions in flat mode', () => {
|
||||
const kept = summary('kept', 1)
|
||||
const gone = summary('gone', 2)
|
||||
expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults archive filtering', () => {
|
||||
it('archived sessions never match — not by title and not via a backend content hit', () => {
|
||||
const hit = summary('hit', 2)
|
||||
hit.displayTitle = 'Needle row'
|
||||
const gone = summary('gone', 1)
|
||||
gone.displayTitle = 'Needle archived'
|
||||
const result = deriveSearchResults(
|
||||
list(hit, gone),
|
||||
[],
|
||||
'needle',
|
||||
archived('gone'),
|
||||
{ items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
|
||||
10,
|
||||
)
|
||||
expect(result.items.map(item => item.id)).toEqual([hit.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults', () => {
|
||||
@@ -160,6 +202,7 @@ describe('deriveSearchResults', () => {
|
||||
workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
|
||||
],
|
||||
' NEEDLE ',
|
||||
noArchive,
|
||||
{
|
||||
items: [
|
||||
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
|
||||
@@ -212,6 +255,7 @@ describe('deriveSearchResults', () => {
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale'])],
|
||||
'new session',
|
||||
noArchive,
|
||||
{
|
||||
items: [
|
||||
{ sessionId: staleBlank.id, snippet: 'stale body' },
|
||||
@@ -234,6 +278,7 @@ describe('deriveSearchResults', () => {
|
||||
list(...rows),
|
||||
[],
|
||||
'needle',
|
||||
noArchive,
|
||||
{ items: [], hasMore: false },
|
||||
3,
|
||||
)
|
||||
@@ -244,12 +289,13 @@ describe('deriveSearchResults', () => {
|
||||
list(summary('body', 1)),
|
||||
[],
|
||||
'needle',
|
||||
noArchive,
|
||||
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
|
||||
3,
|
||||
)
|
||||
expect(backendMore.items).toHaveLength(1)
|
||||
expect(backendMore.hasMore).toBe(true)
|
||||
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3))
|
||||
expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3))
|
||||
.toEqual({ items: [], hasMore: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,8 +35,8 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({
|
||||
items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
function hook<T>(snapshot: T) {
|
||||
@@ -68,6 +68,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
forkSession: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
archiveSession: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
@@ -135,6 +136,50 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('archives a session from the row menu and hides archived rows in both modes', async () => {
|
||||
const archiveSession = vi.fn(async () => {})
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])),
|
||||
archiveSession,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
|
||||
expect(archiveSession).toHaveBeenCalledWith(sid('gone-s'))
|
||||
|
||||
// The archive-set echo hides the row in grouped mode (count included) and flat mode.
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) })
|
||||
expect(screen.queryByText('gone-s')).toBeNull()
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
expect(screen.getByText('kept-s')).toBeTruthy()
|
||||
expect(screen.queryByText('gone-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('logs and keeps the tree when the archive call rejects', async () => {
|
||||
const rejection = new Error('archive exploded')
|
||||
const archiveSession = vi.fn(async () => { throw rejection })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
|
||||
archiveSession,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection)
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a fork child as a top-level row without a session twist', () => {
|
||||
const parent = summary('parent-s', 2)
|
||||
const child = { ...summary('child-s', 1), parentId: parent.id }
|
||||
|
||||
@@ -32,7 +32,7 @@ const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
function anchor(): { current: HTMLElement } {
|
||||
|
||||
Reference in New Issue
Block a user