Merge origin/master (tsx lint lanes, session-telemetry-otel example dep)
This commit is contained in:
@@ -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: 911f18547120eb3dbbc9e42bbcd41e3b6d518cfe
|
||||
README.zh.md: d6e1f0bf9b38b40944f8e3cebea3f6d90dcaceb5
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: d16e395a42e491461c0862227205931894c27e39
|
||||
README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f
|
||||
|
||||
@@ -37,6 +37,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
|
||||
@@ -704,6 +704,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
pickDirectory: request => ok(request, { path: null }),
|
||||
},
|
||||
workspace: {
|
||||
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
|
||||
@@ -952,6 +953,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
@@ -23,7 +24,16 @@ export function apply(ctx: Context): void {
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: (req, res) => bridge(req, res, apiHandler),
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
if (pathname === `${API_PATH}/host.pickDirectory`
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
}
|
||||
|
||||
52
packages/client/connection/src/native-dialog-request.ts
Normal file
52
packages/client/connection/src/native-dialog-request.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/** Trust check for browser requests that can open an operating-system dialog. */
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
interface NativeDialogRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
socket: { remoteAddress?: string | undefined }
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopback(address: string | undefined): boolean {
|
||||
if (address === undefined) return false
|
||||
if (address === '::1') return true
|
||||
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
|
||||
const first = ipv4.split('.')[0]
|
||||
return first === '127'
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a local socket plus browser-controlled same-origin metadata.
|
||||
* @param request - the node HTTP request facts used by the carrier guard.
|
||||
* @returns true only for a same-origin browser request whose peer and URL are loopback.
|
||||
*/
|
||||
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
|
||||
if (!isLoopback(request.socket.remoteAddress)) return false
|
||||
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
|
||||
const origin = header(request.headers, 'origin')
|
||||
const host = header(request.headers, 'host')
|
||||
if (origin === undefined || host === undefined) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
const hostUrl = new URL(`http://${host}`)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
||||
&& parsed.host === host
|
||||
&& isLoopbackHostname(parsed.hostname)
|
||||
&& isLoopbackHostname(hostUrl.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ export class FakeApiClient implements IApiClient {
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -70,6 +72,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
|
||||
47
packages/client/connection/tests/http-bridge.spec.ts
Normal file
47
packages/client/connection/tests/http-bridge.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bridge } from '../src/http-bridge.ts'
|
||||
|
||||
describe('HTTP bridge abort', () => {
|
||||
it('aborts a pending native picker request when the browser disconnects', async () => {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
|
||||
})
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, {
|
||||
url: '/api/host.pickDirectory',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead() { return this },
|
||||
write() { return true },
|
||||
end() { this.writableEnded = true; return this },
|
||||
}) as unknown as ServerResponse
|
||||
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { resolveStarted = resolve })
|
||||
let carrierSignal: AbortSignal | undefined
|
||||
const pending = bridge(request, response, {
|
||||
fetch: async (input) => {
|
||||
const fetchRequest = input as Request
|
||||
carrierSignal = fetchRequest.signal
|
||||
resolveStarted()
|
||||
if (!fetchRequest.signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return Response.json({ aborted: fetchRequest.signal.aborted })
|
||||
},
|
||||
})
|
||||
await started
|
||||
response.emit('close')
|
||||
await pending
|
||||
expect(carrierSignal?.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
|
||||
|
||||
function request(
|
||||
remoteAddress: string | undefined,
|
||||
headers: IncomingHttpHeaders = {
|
||||
host: '127.0.0.1:3080',
|
||||
origin: 'http://127.0.0.1:3080',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
) {
|
||||
return { socket: { remoteAddress }, headers }
|
||||
}
|
||||
|
||||
describe('native dialog request trust', () => {
|
||||
it('accepts loopback same-origin browser requests', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::1', {
|
||||
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
|
||||
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects remote sockets and requests without matching browser metadata', () => {
|
||||
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
|
||||
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
|
||||
}))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
@@ -27,6 +28,23 @@ describe('connection node half', () => {
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url: '/api/host.pickDirectory',
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { WorkspacesService } from './workspaces/service.ts'
|
||||
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface WorkspaceListState {
|
||||
recentWorkspaceId: WorkspaceId | undefined
|
||||
}
|
||||
|
||||
/** Structured create failure for UI flows that distinguish Host business errors. */
|
||||
export class WorkspaceCreateError extends Error {
|
||||
constructor(readonly rpcError: RpcError) {
|
||||
super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
this.name = 'WorkspaceCreateError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Real Workspace object layer and Host actions. */
|
||||
export class WorkspacesService {
|
||||
/** UI-facing immutable projection; the manager remains wire truth. */
|
||||
@@ -37,7 +45,7 @@ export class WorkspacesService {
|
||||
* @param api - shared wire client.
|
||||
* @param sessions - lower-level Session service used for recency and blank-session reuse.
|
||||
*/
|
||||
constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) {
|
||||
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
|
||||
this.manager = new WorkspaceManager(api)
|
||||
this.list = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'pending', error: null,
|
||||
@@ -158,10 +166,22 @@ export class WorkspacesService {
|
||||
*/
|
||||
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
|
||||
const result = await this.manager.create(input)
|
||||
if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (!result.ok) throw new WorkspaceCreateError(result.error)
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Host's native directory picker.
|
||||
* @returns the selected path, or null when the user cancelled.
|
||||
*/
|
||||
async pickDirectory(): Promise<string | null> {
|
||||
const response = await this.api.host.pickDirectory({})
|
||||
if (!response.result.ok) {
|
||||
throw new Error(`directory picker failed: ${response.result.error.message}`)
|
||||
}
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -69,6 +69,8 @@ export class FakeApiClient implements IApiClient {
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -87,6 +89,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
import { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
@@ -210,12 +210,30 @@ describe('WorkspacesService', () => {
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
|
||||
api.onWorkspaceCreate = () => Promise.resolve(ok({
|
||||
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
|
||||
}))
|
||||
await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
|
||||
expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
|
||||
api.onWorkspaceCreate = () => Promise.resolve(err({
|
||||
code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
|
||||
}))
|
||||
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
|
||||
const rejected = workspaces.create({ path: '/missing' })
|
||||
await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/)
|
||||
await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError)
|
||||
})
|
||||
|
||||
it('passes native directory selection and cancellation through without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
|
||||
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
|
||||
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
|
||||
await expect(workspaces.pickDirectory()).resolves.toBeNull()
|
||||
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ async function mountOpen(overrides: Partial<PopupSpec<string>> = {}, consumeResu
|
||||
}
|
||||
|
||||
function rowLabels(): string[] {
|
||||
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!)
|
||||
return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent)
|
||||
}
|
||||
|
||||
describe('PopupSelectView', () => {
|
||||
|
||||
@@ -90,20 +90,17 @@ export function apply(ctx: Context): void {
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
selectWorkspace: (workspaceId) => {
|
||||
void workspaces.connectWorkspace(workspaceId).then((nextId) => {
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
const from = inputHub.shell(sessionId)
|
||||
const draft = from.snapshot.draft
|
||||
if (draft !== '') {
|
||||
inputHub.shell(nextId).setDraft(draft)
|
||||
from.setDraft('')
|
||||
}
|
||||
selectWorkspace: async (workspaceId) => {
|
||||
const nextId = await workspaces.connectWorkspace(workspaceId)
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
const from = inputHub.shell(sessionId)
|
||||
const draft = from.snapshot.draft
|
||||
if (draft !== '') {
|
||||
inputHub.shell(nextId).setDraft(draft)
|
||||
from.setDraft('')
|
||||
}
|
||||
sessions.open(nextId)
|
||||
}).catch(() => {
|
||||
// Failure leaves the current Hero state available to retry.
|
||||
})
|
||||
}
|
||||
sessions.open(nextId)
|
||||
},
|
||||
}),
|
||||
}, ConversationRoot)
|
||||
|
||||
@@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
@@ -103,7 +104,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
})}
|
||||
{subCalls !== undefined && subCalls.length > 0 && (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
{subCalls.map((node) => (
|
||||
{subCalls.map(node => (
|
||||
<SubCallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
@@ -130,7 +131,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
{results.map((node) => (
|
||||
{results.map(node => (
|
||||
<CallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
@@ -154,7 +155,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
useSession: UseConversation
|
||||
onGrow: () => void
|
||||
}) {
|
||||
const partial = useSession((s) => s.partial)
|
||||
const partial = useSession(s => s.partial)
|
||||
useLayoutEffect(() => {
|
||||
onGrow()
|
||||
})
|
||||
@@ -162,17 +163,20 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||||
}
|
||||
|
||||
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
|
||||
/**
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const pending = useSession((s) => s.pending)
|
||||
const openState = useSession((s) => s.openState)
|
||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession((s) => s.hasMore)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
|
||||
@@ -254,8 +258,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some((r) => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
|
||||
&& item.results.some(r => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
|
||||
return (
|
||||
<ToolGroup
|
||||
key={item.key}
|
||||
@@ -280,36 +284,36 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map((call) => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map(call => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
|
||||
@@ -32,6 +32,9 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
|
||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise<void> {
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
@@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise<void> {
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
if (stats.steps === 0) return null
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ToolRow({
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
setExpanded((v) => !v)
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface ToolRowOwnerProps {
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||
openDetails(): void
|
||||
openDetails: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,21 +175,21 @@ export interface ConversationInjected {
|
||||
* Connect the selected Workspace and open its reusable/new blank session.
|
||||
* When a blank session is already current, carry its draft to the target.
|
||||
*/
|
||||
selectWorkspace(workspaceId: WorkspaceId): void
|
||||
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the strict session content seat. */
|
||||
export interface ConversationSessionInjected {
|
||||
/** Views projected from the `conversation.view` slot ledger. */
|
||||
views: {
|
||||
list(): readonly ViewTab[]
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
list: () => readonly ViewTab[]
|
||||
subscribe: (fn: () => void) => () => void
|
||||
version: () => number
|
||||
}
|
||||
/** Bind the input machine's draft persistence mirror to the session store. */
|
||||
bindDraftMirror(write: (text: string) => void): () => void
|
||||
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||
/** Select a real Session through the runtime navigation owner. */
|
||||
open(sessionId: SessionId): void
|
||||
open: (sessionId: SessionId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +219,7 @@ export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop(): void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,8 +275,8 @@ export type ConversationSessionSlotProps =
|
||||
*/
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
loadOlder(): void
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
loadOlder: () => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
|
||||
@@ -290,7 +290,7 @@ export type ChatViewSlotProps =
|
||||
*/
|
||||
export interface DetailsInjected {
|
||||
/** Close the details panel (layout geometry stays with ctx.layout). */
|
||||
closeDetails(): void
|
||||
closeDetails: () => void
|
||||
}
|
||||
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
@@ -300,6 +300,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
onPick(workspaceId: WorkspaceId): void
|
||||
onClose(): void
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { DisabledInputBar } from './DisabledInputBar.tsx'
|
||||
@@ -25,8 +26,23 @@ export function ConversationRoot({
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const sessionWorkspace = sessionId === undefined
|
||||
? undefined
|
||||
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
|
||||
const pendingWorkspace = workspaces.items.find(
|
||||
workspace => workspace.workspaceId === pendingWorkspaceId,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingWorkspaceId !== undefined
|
||||
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) {
|
||||
setPendingWorkspaceId(undefined)
|
||||
}
|
||||
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
|
||||
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
@@ -36,9 +52,10 @@ export function ConversationRoot({
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={
|
||||
sessionId === undefined
|
||||
pendingWorkspace?.title
|
||||
?? (sessionId === undefined
|
||||
? workspaceLabel('')
|
||||
: workspaces.items.find(w => w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '')
|
||||
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
|
||||
}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
@@ -48,7 +65,10 @@ export function ConversationRoot({
|
||||
anchorRef: pickerAnchor,
|
||||
onPick: (workspaceId) => {
|
||||
setPickerOpen(false)
|
||||
selectWorkspace(workspaceId)
|
||||
setPendingWorkspaceId(workspaceId)
|
||||
void selectWorkspace(workspaceId).catch(() => {
|
||||
setPendingWorkspaceId(current => current === workspaceId ? undefined : current)
|
||||
})
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
@@ -58,12 +78,12 @@ export function ConversationRoot({
|
||||
const inputBar = sessionId === undefined
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
})
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
})
|
||||
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
|
||||
@@ -41,8 +41,8 @@ export function ConversationSession({
|
||||
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
||||
const unmirror = bindDraftMirror(actions.setDraft)
|
||||
return () => { unmirror() }
|
||||
// Mount-only: later store writes come from the machine mirror.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// Mount-only (deps pinned to inputActions): later store writes come from
|
||||
// the machine mirror, not this seed effect.
|
||||
}, [inputActions])
|
||||
|
||||
if (blank && composerPhase === 'blank') return null
|
||||
|
||||
@@ -86,27 +86,27 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
: material === null
|
||||
? <div className={css.empty}>该调用不在当前窗口内</div>
|
||||
: (
|
||||
<>
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
</section>
|
||||
)}
|
||||
<>
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,11 @@ export function InputBar({
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot)
|
||||
const noticeStore = keyboard.notices
|
||||
const notice = useSyncExternalStore(
|
||||
(fn: () => void) => noticeStore.subscribe(fn),
|
||||
() => noticeStore.getSnapshot(),
|
||||
)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
@@ -75,6 +79,8 @@ export function InputBar({
|
||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||
if (e.key === 'Enter' && e.shiftKey) return
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
|
||||
@@ -92,7 +98,7 @@ export function InputBar({
|
||||
// the browser stack cannot represent); never let the native stack run.
|
||||
e.preventDefault()
|
||||
if (machineBusy || locked) return
|
||||
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z'))
|
||||
const redo = e.key === 'y' || e.shiftKey
|
||||
if (redo) keyboard.redo()
|
||||
else keyboard.undo()
|
||||
return
|
||||
@@ -134,6 +140,8 @@ export function InputBar({
|
||||
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
||||
const next = e.target.value
|
||||
keyboard.setDraft(next)
|
||||
// selectionStart is number|null in lib.dom; the eslint program narrows it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
keyboard.track(next, e.target.selectionStart ?? next.length)
|
||||
}
|
||||
|
||||
@@ -145,10 +153,13 @@ export function InputBar({
|
||||
// too (one char = one step). Mouse selection of a chip is handled in the
|
||||
// backdrop click handler below. Undo/redo must NOT reach the browser: the
|
||||
// machine owns the transaction log.
|
||||
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
const selectionOf = (el: HTMLTextAreaElement) => ({
|
||||
start: el.selectionStart ?? 0,
|
||||
end: el.selectionEnd ?? el.selectionStart ?? 0,
|
||||
})
|
||||
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||
|
||||
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
||||
const el = e.currentTarget
|
||||
@@ -330,8 +341,8 @@ export function InputBar({
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={e => { onCopyOrCut(e, false) }}
|
||||
onCut={e => { onCopyOrCut(e, true) }}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
|
||||
@@ -36,10 +36,12 @@ const SCOPE_TAG: symbol = (() => {
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver) {
|
||||
recorded.push(prop)
|
||||
// Reflect.get is typed any; the probe only records property names.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy as Context)
|
||||
void scopeOf(spy)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
@@ -73,14 +75,15 @@ async function bench() {
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
|
||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
|
||||
scopes.set(id, scoped)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
type TestProvider = {
|
||||
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
|
||||
hooks?: Record<string, unknown>; props?: Record<string, unknown>
|
||||
hooks?: Record<string, unknown>
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
@@ -158,10 +161,12 @@ async function bench() {
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||
const state = contribution.hooks!['input'] as {
|
||||
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
|
||||
getSnapshot: () => { draft: string }
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
const actions = contribution.props!['inputActions'] as {
|
||||
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
|
||||
setDraft: (text: string) => void
|
||||
submit: (mode?: 'queue' | 'steer') => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
@@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => {
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||
// Unknown session: sessions.scope answers nothing.
|
||||
;(b.sessionsFake.scope as unknown) = () => undefined
|
||||
expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/)
|
||||
expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
|
||||
// A scope minted outside the service tree: no conversation service on it.
|
||||
const foreign = new Context()
|
||||
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
|
||||
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
|
||||
expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
|
||||
})
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
|
||||
// no draft movement, plain re-open.
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
resident.selectWorkspace('workspace-1' as never)
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
|
||||
// new session's machine receives the text, then navigation lands there.
|
||||
const OTHER = 'other-1' as SessionId
|
||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||
resident.selectWorkspace('workspace-2' as never)
|
||||
void resident.selectWorkspace('workspace-2' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
|
||||
@@ -31,7 +31,7 @@ async function bench() {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
@@ -83,7 +83,7 @@ describe('apply wiring', () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
expect(entries[0]?.options.label).toBe('Chat')
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
@@ -117,7 +117,7 @@ describe('apply wiring', () => {
|
||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
})
|
||||
|
||||
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ function snapshotWith(
|
||||
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
|
||||
@@ -36,7 +36,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
@@ -100,9 +100,9 @@ describe('StatsLine', () => {
|
||||
render(<Counting {...props(source)} />)
|
||||
const before = renders
|
||||
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }))
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }))
|
||||
act(() => set({ running: true }))
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) })
|
||||
act(() => { set({ running: true }) })
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -128,7 +128,7 @@ describe('bash sample row', () => {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
}
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
|
||||
@@ -42,7 +42,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
return info
|
||||
},
|
||||
maybeProvideInfo(id: string | undefined) {
|
||||
// `this` inside an object-literal method is any under strict lint; the
|
||||
// fake resolves through its own provideInfo above.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
@@ -252,7 +256,7 @@ describe('registrant load-order seam', () => {
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
|
||||
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -39,7 +40,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
@@ -104,8 +105,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||
useInput: (() => { throw new Error('unused') }),
|
||||
inputActions: { setDraft: () => {}, submit: () => {} },
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
@@ -124,9 +125,9 @@ describe('chat-flow derivation', () => {
|
||||
assistant(5, 'found'), toolResult(6, 'c'),
|
||||
]
|
||||
const items = deriveChatFlow(nodes)
|
||||
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||
const group = items[2]!
|
||||
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
|
||||
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
@@ -155,7 +156,7 @@ describe('ChatView', () => {
|
||||
fireEvent.scroll(scroller)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
|
||||
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
|
||||
})
|
||||
|
||||
@@ -240,10 +241,10 @@ describe('ChatView', () => {
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = (((_key: string, _owner: object) => {
|
||||
h.props.renderSlot = ((_key: string, _owner: object) => {
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||
const afterMount = rowRenders
|
||||
@@ -270,7 +271,7 @@ describe('ChatView', () => {
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
|
||||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -284,10 +285,10 @@ describe('ChatView', () => {
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
||||
return opts?.fallback ?? null
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
})
|
||||
render(<h.ChatView {...h.props} />)
|
||||
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
|
||||
// name, and the fallback (GenericToolCard) renders on an empty ledger.
|
||||
@@ -306,10 +307,10 @@ describe('ChatView', () => {
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
|
||||
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
|
||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
|
||||
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
@@ -324,7 +325,7 @@ describe('ChatView', () => {
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
|
||||
act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
|
||||
expect(scroller.scrollTop).toBe(100)
|
||||
fireEvent.click(backButton)
|
||||
expect(scroller.scrollTop).toBe(1000)
|
||||
@@ -337,7 +338,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||
act(() => h.set({ loadingOlder: true }))
|
||||
act(() => { h.set({ loadingOlder: true }) })
|
||||
expect(view.getByText('加载中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
@@ -83,7 +83,7 @@ describe('tails', () => {
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
|
||||
@@ -21,7 +21,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
describe('render branch tails', () => {
|
||||
@@ -73,11 +73,11 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
@@ -108,11 +108,11 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
|
||||
@@ -79,11 +79,11 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -212,7 +212,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const { textarea, wiring } = bench()
|
||||
fireEvent.change(textarea, { target: { value: 'typed' } })
|
||||
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('typed')
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
@@ -364,10 +364,10 @@ describe('placeholder chrome and control seats', () => {
|
||||
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
||||
expect(view.getByTestId('model-entry')).toBeTruthy()
|
||||
// The bar hands its chrome disable state to the filling entry.
|
||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
|
||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true })
|
||||
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
|
||||
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
})
|
||||
|
||||
it('disabled locks the Access placeholder and attach control (running does not)', () => {
|
||||
|
||||
@@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -88,7 +88,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
||||
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
|
||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
|
||||
expect((textarea).readOnly).toBe(false)
|
||||
// Free editing beyond the token: hint drops, claim holds.
|
||||
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
||||
expect(shell.snapshot.phase).toBe('claimed')
|
||||
@@ -104,7 +104,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
|
||||
// Commit: draft cleared, notice surfaced, back to plain.
|
||||
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') })
|
||||
await vi.waitFor(() => { expect((textarea).value).toBe('') })
|
||||
expect(view.getByText('完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('matrix row: submitting', () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
expect(shell.snapshot.claim).toBeDefined()
|
||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true)
|
||||
expect((textarea).readOnly).toBe(true)
|
||||
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
|
||||
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
|
||||
@@ -145,7 +145,7 @@ describe('matrix row: submitting', () => {
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
|
||||
act(() => { rejectSubmit(new Error('执行失败')) })
|
||||
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
|
||||
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ')
|
||||
expect((first.textarea).value).toBe('/goal ')
|
||||
expect(first.view.getByText('执行失败')).toBeTruthy()
|
||||
cleanup()
|
||||
// Drift: typing during flight wins; no restore, plain, notice only.
|
||||
@@ -157,7 +157,7 @@ describe('matrix row: submitting', () => {
|
||||
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
|
||||
act(() => { rejectSubmit(new Error('晚到失败')) })
|
||||
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
|
||||
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿')
|
||||
expect((second.textarea).value).toBe('用户飞行中打的新稿')
|
||||
expect(second.view.getByText('晚到失败')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -165,14 +165,14 @@ describe('matrix row: submitting', () => {
|
||||
describe('matrix row: locked (session disabled)', () => {
|
||||
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
||||
const { view, textarea, shell } = bench({ disabled: true })
|
||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(true)
|
||||
expect((textarea).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
|
||||
const { textarea, sink } = bench({ running: true })
|
||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
@@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
await ctx.plugin(SlashService).await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
register?.(slash)
|
||||
const actx = sessions.scope(sessionId)! as ClientContext
|
||||
const actx = sessions.scope(sessionId)!
|
||||
const controller = slash.sessionOf(actx)
|
||||
const sink = vi.fn()
|
||||
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
|
||||
@@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -134,7 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const type = (text: string): void => {
|
||||
fireEvent.change(textarea, { target: { value: text } })
|
||||
}
|
||||
@@ -145,7 +144,7 @@ async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
|
||||
const execute = vi.fn(executeImpl ?? ((line: string) =>
|
||||
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
|
||||
const { source, executed } = commandSource(COMMANDS, execute)
|
||||
const base = await scopedBench((slash) => { slash.registerSource(source as never) })
|
||||
const base = await scopedBench((slash) => { slash.registerSource(source) })
|
||||
return { ...base, execute, executed }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// hero (blank session) and active phases — same textarea DOM node, machine-
|
||||
// owned draft, and the hero workspace picker (switching = retargetWorkspace).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -55,7 +55,11 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
|
||||
}
|
||||
}
|
||||
|
||||
function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
|
||||
function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
@@ -76,7 +80,6 @@ function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] =
|
||||
const inputActions = wiring.actions
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const retargetWorkspace = vi.fn()
|
||||
const slotCalls: string[] = []
|
||||
let pickerOwner: unknown
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
@@ -158,7 +161,13 @@ describe('ConversationRoot resident composer', () => {
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
[
|
||||
{ ...workspace('one'), sessionIds: [SID] },
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
@@ -173,8 +182,9 @@ describe('ConversationRoot resident composer', () => {
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
owner.onPick(wid('second'))
|
||||
act(() => { owner.onPick(wid('second')) })
|
||||
expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
@@ -191,6 +201,24 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rolls the pending workspace label back when switching fails', async () => {
|
||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
[
|
||||
{ ...workspace('one'), sessionIds: [SID] },
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
selectWorkspace,
|
||||
)
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
|
||||
await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
|
||||
expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
expect(b.view.queryByText('Selected Folder')).toBeNull()
|
||||
expect(b.view.getByText('one')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
|
||||
|
||||
@@ -33,7 +33,10 @@ function DetailsColumn(props: { children?: ReactNode }) {
|
||||
return <div className={css.detailsCol}>{props.children}</div>
|
||||
}
|
||||
|
||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */
|
||||
/**
|
||||
* One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin.
|
||||
* `side` keys the hover-reveal CSS to the owning column.
|
||||
*/
|
||||
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const origin = useRef(0)
|
||||
@@ -86,7 +89,7 @@ export function AppFrame({
|
||||
actions,
|
||||
renderSlot,
|
||||
}: AppFrameProps) {
|
||||
const panels = useStore((s) => s)
|
||||
const panels = useStore(s => s)
|
||||
const frameRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewport, setViewport] = useState(() => window.innerWidth)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ let fireResize: (() => void) | null = null
|
||||
class ResizeObserverStub {
|
||||
#cb: ResizeObserverCallback
|
||||
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
|
||||
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } }
|
||||
observe(): void { fireResize = () => { this.#cb([], this) } }
|
||||
unobserve(): void {}
|
||||
disconnect(): void { fireResize = null }
|
||||
}
|
||||
@@ -48,7 +48,7 @@ let frameWidth = 1920
|
||||
|
||||
/** Test-local selector hook over a framework-neutral store instance. */
|
||||
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
|
||||
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
|
||||
return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
|
||||
}
|
||||
|
||||
function mountFrame() {
|
||||
@@ -118,7 +118,7 @@ beforeEach(() => {
|
||||
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
|
||||
window.innerWidth = frameWidth
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
||||
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
|
||||
}
|
||||
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
|
||||
const captured = new WeakSet<Element>()
|
||||
@@ -143,12 +143,12 @@ describe('AppFrame', () => {
|
||||
const { slotCalls, getByTestId } = mountFrame()
|
||||
expect(getByTestId('center-content')).toBeTruthy()
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
const keys = slotCalls.map((c) => c.key)
|
||||
const keys = slotCalls.map(c => c.key)
|
||||
expect(keys).toContain('conversation')
|
||||
expect(keys).toContain('details')
|
||||
expect(keys).not.toContain('conversation.empty')
|
||||
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
|
||||
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
|
||||
expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
|
||||
expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps the conversation slot mounted while no session is current', () => {
|
||||
@@ -157,7 +157,7 @@ describe('AppFrame', () => {
|
||||
sessionMode.current = false
|
||||
const { slotCalls, getByTestId } = mountFrame()
|
||||
expect(getByTestId('center-content')).toBeTruthy()
|
||||
expect(slotCalls.map((c) => c.key)).toContain('conversation')
|
||||
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
||||
})
|
||||
|
||||
it('renders both column occupants before baselines settle (no loading gate)', () => {
|
||||
@@ -165,13 +165,13 @@ describe('AppFrame', () => {
|
||||
// pending rendering — both occupants mount from first paint.
|
||||
baselinesReady.current = false
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.map((c) => c.key)).toContain('conversation')
|
||||
expect(slotCalls.map((c) => c.key)).toContain('details')
|
||||
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
||||
expect(slotCalls.map(c => c.key)).toContain('details')
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
||||
expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
||||
})
|
||||
|
||||
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
||||
@@ -211,7 +211,7 @@ describe('AppFrame', () => {
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
|
||||
expect(getByTestId('sidebar-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
||||
const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)!
|
||||
const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
|
||||
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr
|
||||
variant?: ButtonVariant
|
||||
size?: 'md' | 'sm'
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
className?: string | undefined
|
||||
children?: ReactNode
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
|
||||
@@ -158,63 +158,63 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
// (open/toggle) after onSelect.
|
||||
onClick={(e) => { e.stopPropagation() }}
|
||||
>
|
||||
{items.map(entry => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
if (isLabel(entry)) {
|
||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||
}
|
||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||
const subOpen = hasSub && openSubmenuId === entry.id
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={css.itemWrap}
|
||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
||||
disabled={entry.disabled}
|
||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onClick={() => {
|
||||
if (hasSub) {
|
||||
setOpenSubmenuId(entry.id)
|
||||
return
|
||||
}
|
||||
onSelect(entry.id)
|
||||
}}
|
||||
>
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={css.item}
|
||||
disabled={sub.disabled}
|
||||
onClick={() => { onSelect(sub.id) }}
|
||||
>
|
||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||
<span className={css.itemLabel}>{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{items.map((entry) => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
if (isLabel(entry)) {
|
||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||
}
|
||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||
const subOpen = hasSub && openSubmenuId === entry.id
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={css.itemWrap}
|
||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
|
||||
disabled={entry.disabled}
|
||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onClick={() => {
|
||||
if (hasSub) {
|
||||
setOpenSubmenuId(entry.id)
|
||||
return
|
||||
}
|
||||
onSelect(entry.id)
|
||||
}}
|
||||
>
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={css.item}
|
||||
disabled={sub.disabled}
|
||||
onClick={() => { onSelect(sub.id) }}
|
||||
>
|
||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||
<span className={css.itemLabel}>{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ interface AnchorProps {
|
||||
* Attach a hover/focus tooltip to an anchor element.
|
||||
* @param props.label - bubble text.
|
||||
* @param props.side - placement relative to the anchor (default 'right').
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||
* toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
|
||||
@@ -544,14 +544,14 @@ export const IconApiOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<path transform="translate(0.6689 1.073)" d="M11.4818 5.57813C11.4818 4.45301 11.4807 3.66237 11.4075 3.05908C11.3359 2.46953 11.2024 2.13852 10.9939 1.89441C10.9247 1.81341 10.8493 1.73801 10.7683 1.66882C10.5242 1.46033 10.1932 1.32686 9.60364 1.25525C9.00034 1.18198 8.20974 1.18091 7.0846 1.18091L5.57813 1.18091C4.45301 1.18091 3.66238 1.18198 3.05908 1.25525C2.46953 1.32686 2.13852 1.46033 1.89441 1.66882C1.81341 1.73801 1.73801 1.81341 1.66882 1.89441C1.46033 2.13852 1.32686 2.46953 1.25525 3.05908C1.18198 3.66238 1.18091 4.45301 1.18091 5.57813L1.18091 6.2771C1.18091 7.40218 1.18197 8.19288 1.25525 8.79614C1.32687 9.38553 1.46036 9.71674 1.66882 9.96082C1.73797 10.0417 1.81347 10.1173 1.89441 10.1864C2.13851 10.3948 2.46965 10.5275 3.05908 10.5991C3.66238 10.6724 4.45298 10.6735 5.57813 10.6735L7.0846 10.6735C8.20977 10.6735 9.00033 10.6724 9.60364 10.5991C10.1931 10.5275 10.5242 10.3948 10.7683 10.1864C10.8493 10.1173 10.9247 10.0417 10.9939 9.96082C11.2024 9.71674 11.3358 9.38553 11.4075 8.79614C11.4808 8.19288 11.4818 7.40218 11.4818 6.2771L11.4818 5.57813ZM12.6627 6.2771C12.6627 7.37222 12.6637 8.247 12.5798 8.93799C12.4942 9.64284 12.3133 10.2359 11.8928 10.7282C11.7834 10.8562 11.6637 10.9751 11.5356 11.0845C11.0434 11.5049 10.4511 11.6867 9.74634 11.7723C9.05525 11.8563 8.17999 11.8552 7.0846 11.8552L5.57813 11.8552C4.48273 11.8552 3.60747 11.8563 2.91638 11.7723C2.21157 11.6867 1.61933 11.5049 1.12708 11.0845C0.99901 10.9751 0.879281 10.8562 0.769898 10.7282C0.349454 10.2359 0.168506 9.64284 0.0828864 8.93799C-0.00101964 8.247 4.88512e-07 7.37222 6.47206e-07 6.2771L6.47206e-07 5.57813C6.47206e-07 4.48273 -0.00106163 3.60747 0.0828864 2.91638C0.168502 2.21168 0.349594 1.61928 0.769898 1.12708C0.879302 0.998981 0.998981 0.879302 1.12708 0.769898C1.61928 0.349594 2.21168 0.168502 2.91638 0.0828864C3.60747 -0.00106163 4.48273 6.47206e-07 5.57813 6.47206e-07L7.0846 6.47206e-07C8.17999 6.47206e-07 9.05525 -0.00106163 9.74634 0.0828864C10.451 0.168505 11.0434 0.349587 11.5356 0.769898C11.6637 0.879302 11.7834 0.998981 11.8928 1.12708C12.3131 1.61928 12.4942 2.21169 12.5798 2.91638C12.6638 3.60747 12.6627 4.48273 12.6627 5.57813L12.6627 6.2771Z" fill="currentColor"/>
|
||||
<path transform="translate(0.6689 1.073)" d="M6.02607 5.50955L6.44306 5.9274L3.84284 8.52762L3.425 8.11063L3.00715 7.69278L4.77253 5.9274L3.00715 4.16202L3.84284 3.32633L6.02607 5.50955Z" fill="currentColor"/>
|
||||
<path transform="translate(0.6689 1.073)" d="M9.23789 7.35397L9.23789 8.53488L6.96238 8.53488L6.96238 7.35397L9.23789 7.35397Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_personalization_outline_16 (figma extract) */
|
||||
export const IconPersonalizationOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(1.292 1.3)" d="M10.3232 9.18164C11.2868 9.18164 12.0985 9.82833 12.3506 10.7109L13.415 10.7109L13.415 11.8711L12.3496 11.8711C12.0971 12.7532 11.2864 13.3994 10.3232 13.3994C9.36031 13.3992 8.55012 12.7531 8.29785 11.8711L0 11.8711L0 10.7109L8.29688 10.7109C8.54876 9.82845 9.35988 9.18186 10.3232 9.18164ZM10.3232 10.3418C9.7999 10.3421 9.37534 10.7667 9.375 11.29C9.375 11.8137 9.79969 12.239 10.3232 12.2393C10.847 12.2393 11.2725 11.8138 11.2725 11.29C11.2721 10.7666 10.8468 10.3418 10.3232 10.3418ZM12.4326 11.291C12.4326 11.3549 12.4284 11.418 12.4229 11.4805C12.4287 11.4181 12.4326 11.355 12.4326 11.291ZM8.21484 11.2832C8.21484 11.2856 8.21484 11.2886 8.21484 11.291L8.21484 11.29C8.21484 11.2878 8.21484 11.2855 8.21484 11.2832ZM3.08301 4.59082C4.04605 4.59095 4.85696 5.23717 5.10938 6.11914L13.415 6.11914L13.415 7.2793L5.11035 7.2793C4.85833 8.16202 4.04648 8.80846 3.08301 8.80859C2.11972 8.80843 1.30963 8.16179 1.05762 7.2793L0 7.2793L0 6.11914L1.05762 6.11914C1.30994 5.23728 2.12006 4.59098 3.08301 4.59082ZM3.08301 5.75098C2.55962 5.75117 2.13512 6.17587 2.13477 6.69922C2.13477 7.22287 2.5594 7.64824 3.08301 7.64844C3.60665 7.64828 4.03223 7.2229 4.03223 6.69922C4.03187 6.17585 3.60643 5.75113 3.08301 5.75098ZM5.19238 6.69922C5.19238 6.763 5.18816 6.82633 5.18262 6.88867C5.18846 6.82629 5.19238 6.76313 5.19238 6.69922C5.19236 6.63495 5.18853 6.57152 5.18262 6.50879C5.18826 6.57154 5.19236 6.635 5.19238 6.69922ZM0.982422 6.52344C0.977382 6.58136 0.97463 6.63999 0.974609 6.69922C0.974609 6.75775 0.977496 6.81579 0.982422 6.87305C0.977758 6.81579 0.974609 6.75767 0.974609 6.69922C0.974628 6.64 0.977618 6.58142 0.982422 6.52344ZM10.3232 0C11.2869 0 12.0986 0.646596 12.3506 1.5293L13.415 1.5293L13.415 2.68945L12.3496 2.68945C12.363 2.64266 12.3754 2.59488 12.3857 2.54688C12.1838 3.50118 11.3376 4.21777 10.3232 4.21777C9.36037 4.21756 8.55018 3.57139 8.29785 2.68945L0 2.68945L0 1.5293L8.29688 1.5293C8.5487 0.646717 9.35981 0.00021854 10.3232 0ZM10.3232 1.16016C9.79984 1.16042 9.37524 1.58499 9.375 2.1084C9.375 2.63201 9.79969 3.05735 10.3232 3.05762C10.847 3.05762 11.2725 2.63217 11.2725 2.1084C11.2722 1.58483 10.8469 1.16016 10.3232 1.16016ZM12.4229 2.29883C12.4287 2.23641 12.4326 2.17331 12.4326 2.10938C12.4326 2.17327 12.4284 2.23638 12.4229 2.29883ZM8.21484 2.10938L8.21484 2.1084L8.21484 2.10938ZM8.22266 1.93359C8.21785 1.98897 8.21506 2.04499 8.21484 2.10156C8.21503 2.04501 8.2181 1.98902 8.22266 1.93359ZM8.22266 11.1162C8.2179 11.1713 8.21507 11.227 8.21484 11.2832C8.21504 11.227 8.21814 11.1713 8.22266 11.1162Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_project_add_outline_16 (figma extract) */
|
||||
@@ -559,7 +559,7 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(9.52 2.52)" d="M3.55246 0L3.55246 2.44252L6 2.44252L6 3.55748L3.55246 3.55748L3.55246 6L2.43834 6L2.43834 3.55748L0 3.55748L0 2.44252L2.43834 2.44252L2.43834 0L3.55246 0Z" fill="currentColor"/>
|
||||
<path transform="translate(0.3496 2.35)" d="M4.76367 0C5.36861 1.80598e-05 5.93113 0.310294 6.25488 0.821289L6.78027 1.64941C6.79685 1.67558 6.81791 1.69775 6.83887 1.71973C6.72186 2.15521 6.65702 2.61192 6.65137 3.08301C6.25601 2.96045 5.90909 2.70478 5.68164 2.3457L5.15723 1.5166C5.07183 1.38189 4.92318 1.3008 4.76367 1.30078L2.32422 1.30078C1.7589 1.30078 1.30078 1.7589 1.30078 2.32422L1.30078 10.1338C1.30078 10.6991 1.7589 11.1572 2.32422 11.1572L11.9766 11.1572C12.5419 11.1572 13 10.6991 13 10.1338L13 8.58398C13.4545 8.5135 13.8903 8.38748 14.3008 8.21289L14.3008 10.1338C14.3008 11.4171 13.2598 12.458 11.9766 12.458L2.32422 12.458C1.04093 12.458 0 11.4171 0 10.1338L0 2.32422C0 1.04093 1.04093 0 2.32422 0L4.76367 0Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */
|
||||
@@ -567,14 +567,14 @@ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
|
||||
<path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** folder_close_16 (figma extract) */
|
||||
export const IconFolderClose16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(1.5 2.429)" d="M5.05582 0.518756L4.50669 0.86654L5.05582 0.518756ZM13 9.4837L13.65 9.4837L13.65 3.53962L13 3.53962L12.35 3.53962L12.35 9.4837L13 9.4837ZM11.3264 1.86603L11.3264 1.21603L6.52313 1.21603L6.52313 1.86603L6.52313 2.51603L11.3264 2.51603L11.3264 1.86603ZM5.58054 1.34727L6.12968 0.999489L5.60495 0.170972L5.05582 0.518756L4.50669 0.86654L5.03141 1.69506L5.58054 1.34727ZM4.11323 1.23058e-13L4.11323 -0.65L1.67359 -0.65L1.67359 5.00699e-14L1.67359 0.65L4.11323 0.65L4.11323 1.23058e-13ZM0 1.67359L-0.65 1.67359L-0.65 9.4837L0 9.4837L0.65 9.4837L0.65 1.67359L0 1.67359ZM11.3264 11.1573L11.3264 10.5073L1.67359 10.5073L1.67359 11.1573L1.67359 11.8073L11.3264 11.8073L11.3264 11.1573ZM0 9.4837L-0.65 9.4837C-0.65 10.767 0.390308 11.8073 1.67359 11.8073L1.67359 11.1573L1.67359 10.5073C1.10828 10.5073 0.65 10.049 0.65 9.4837L0 9.4837ZM1.67359 5.00699e-14L1.67359 -0.65C0.390307 -0.65 -0.65 0.390309 -0.65 1.67359L0 1.67359L0.65 1.67359C0.65 1.10828 1.10828 0.65 1.67359 0.65L1.67359 5.00699e-14ZM5.05582 0.518756L5.60495 0.170972C5.28121 -0.340193 4.71829 -0.65 4.11323 -0.65L4.11323 1.23058e-13L4.11323 0.65C4.27282 0.65 4.4213 0.731715 4.50669 0.86654L5.05582 0.518756ZM6.52313 1.86603L6.52313 1.21603C6.36354 1.21603 6.21507 1.13431 6.12968 0.999489L5.58054 1.34727L5.03141 1.69506C5.35515 2.20622 5.91808 2.51603 6.52313 2.51603L6.52313 1.86603ZM13 3.53962L13.65 3.53962C13.65 2.25634 12.6097 1.21603 11.3264 1.21603L11.3264 1.86603L11.3264 2.51603C11.8917 2.51603 12.35 2.97431 12.35 3.53962L13 3.53962ZM13 9.4837L12.35 9.4837C12.35 10.049 11.8917 10.5073 11.3264 10.5073L11.3264 11.1573L11.3264 11.8073C12.6097 11.8073 13.65 10.767 13.65 9.4837L13 9.4837Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** tree_corner_8x10 (figma extract; session-tree "L" connector, stroke geometry pre-expanded) */
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface CodeBlockProps {
|
||||
|
||||
/** @returns true only when the host accepted the write. */
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -30,6 +33,9 @@ async function writeClipboard(text: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
// jsdom and older hosts: best-effort execCommand path when present.
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing; deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
@@ -48,6 +54,7 @@ async function writeClipboard(text: string): Promise<boolean> {
|
||||
} finally {
|
||||
el.remove()
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
@@ -64,20 +71,20 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1000)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, trimmed])
|
||||
|
||||
const body = html === undefined
|
||||
? (
|
||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||
)
|
||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||
)
|
||||
: (
|
||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
||||
// span tree it generated from `code` (no user HTML passes through), the
|
||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)
|
||||
// shiki's output is a static span tree it generated from `code` (no user
|
||||
// HTML passes through), the sanctioned innerHTML consumption path per
|
||||
// shiki's own docs.
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||
|
||||
@@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
if (!open) return ''
|
||||
let s: string
|
||||
try {
|
||||
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
s = JSON.stringify(payload, null, 2) ?? String(payload)
|
||||
} catch {
|
||||
s = String(payload)
|
||||
@@ -23,7 +25,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
}, [open, payload])
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<button type="button" className={css.toggle} onClick={() => setOpen((v) => !v)}>
|
||||
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
|
||||
{open ? '▾' : '▸'} {label}
|
||||
</button>
|
||||
{open && <pre className={css.body}>{body}</pre>}
|
||||
|
||||
@@ -27,25 +27,25 @@ const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean): Components {
|
||||
return {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Fenced blocks route through the shared CodeBlock (shiki for registered
|
||||
// grammars, identical-geometry plain fallback for unknown/absent
|
||||
// languages); inline code keeps the default <code> path (the :not(pre)
|
||||
@@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components {
|
||||
// plain arm — retokenizing a growing fence on every chunk is quadratic
|
||||
// main-thread work; the finalize swap highlights it once.
|
||||
pre: ({ children }) => {
|
||||
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
|
||||
// The markdown pipeline always hands `pre` its single `code` element;
|
||||
// the undefined arm guards a react-markdown representation change.
|
||||
/* v8 ignore next 2 */
|
||||
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
|
||||
const raw = child?.props.children
|
||||
// A fence whose content isn't one plain string (e.g. an empty fence)
|
||||
|
||||
@@ -13,7 +13,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
|
||||
wrapper.getBoundingClientRect = () => ({
|
||||
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
|
||||
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
}
|
||||
|
||||
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('ic_ds_ icon set', () => {
|
||||
expect(iconNames.length).toBe(55)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
const Icon = icons[name]!
|
||||
const { container } = render(<Icon />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('JsonBlock', () => {
|
||||
it('truncates beyond the size cap with a suffix note', () => {
|
||||
const big = 'x'.repeat(30_000)
|
||||
const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
|
||||
const body = container.querySelector('pre')!.textContent!
|
||||
const body = container.querySelector('pre')!.textContent
|
||||
expect(body.length).toBeLessThan(30_000)
|
||||
expect(body).toContain('截断')
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('StateDot', () => {
|
||||
it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', state => {
|
||||
it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', (state) => {
|
||||
const { container } = render(<StateDot state={state} />)
|
||||
const dot = container.firstElementChild as HTMLElement
|
||||
expect(dot.dataset['state']).toBe(state)
|
||||
|
||||
@@ -128,6 +128,11 @@
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
/* Rows are the scroll content, never the slack absorber: a shrinkable row
|
||||
collapses to min-height while its wrapped copy keeps the taller
|
||||
intrinsic height, and centered content then paints outside the row box —
|
||||
over the title and the next row. Overflow belongs to .options. */
|
||||
flex-shrink: 0;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
@@ -208,6 +213,9 @@
|
||||
}
|
||||
|
||||
.custom {
|
||||
/* Same reason as .option: the custom block is scroll content, and shrinking
|
||||
it pushes its trigger row (and the open textarea) past the footer. */
|
||||
flex-shrink: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string {
|
||||
|
||||
/** Return whether a textarea key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||
}
|
||||
|
||||
@@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const draft = drafts[index]!
|
||||
const hasOptions = (question.options?.length ?? 0) > 0
|
||||
|
||||
@@ -145,10 +150,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
const skipQuestion = (): void => {
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||
? {
|
||||
selected: [], custom: '',
|
||||
customOpen: (question.options?.length ?? 0) === 0,
|
||||
skipped: true,
|
||||
}
|
||||
selected: [], custom: '',
|
||||
customOpen: (question.options?.length ?? 0) === 0,
|
||||
skipped: true,
|
||||
}
|
||||
: item)
|
||||
setDrafts(nextDrafts)
|
||||
setError(null)
|
||||
|
||||
@@ -50,7 +50,7 @@ const QUESTIONS = [
|
||||
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
|
||||
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
|
||||
const carrier = new PendingWait(
|
||||
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
|
||||
'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond)
|
||||
return { carrier, respond }
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('QuestionComposer', () => {
|
||||
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
||||
]))
|
||||
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('skips individual questions without discarding earlier answers', () => {
|
||||
@@ -173,7 +173,7 @@ describe('QuestionComposer', () => {
|
||||
// Receipt rejection surfaces through the domain face's thrown message.
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
|
||||
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
||||
@@ -199,7 +199,7 @@ describe('QuestionComposer', () => {
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(await screen.findByText('网络中断')).toBeTruthy()
|
||||
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('GeneralSection', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Permission')).toBeTruthy()
|
||||
expect(screen.getByText('Choose default permission mode')).toBeTruthy()
|
||||
const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement
|
||||
const selector = screen.getByRole<HTMLButtonElement>('button', { name: /Read only/ })
|
||||
expect(selector.disabled).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
// Local selection; entries can unmount underneath it, so the render-time
|
||||
// projection falls back to the first row when the id is gone.
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id
|
||||
const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id
|
||||
const titleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,7 +56,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
<nav className={css.nav}>
|
||||
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
|
||||
<div className={css.navList}>
|
||||
{rows.map((row) => (
|
||||
{rows.map(row => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
|
||||
@@ -26,7 +26,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
|
||||
if (key === 'sidebar.settings') {
|
||||
settingsOwner = owner as SidebarSettingsOwnerProps
|
||||
settingsOwner = owner
|
||||
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
||||
}
|
||||
regionOwner = owner as SidebarSectionOwnerProps
|
||||
|
||||
@@ -34,5 +34,5 @@ export interface MenuViewInjected {
|
||||
* @param source - source (group) name.
|
||||
* @param index - candidate index within the group.
|
||||
*/
|
||||
onPick(source: string, index: number): void
|
||||
onPick: (source: string, index: number) => void
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function createPanelStore() {
|
||||
})
|
||||
}
|
||||
|
||||
const chatStore = () => defineStore({
|
||||
const _chatStore = () => defineStore({
|
||||
init: () => ({ selection: null as { id: string } | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, t: { id: string }) => { d.selection = t },
|
||||
@@ -49,7 +49,7 @@ const chatStore = () => defineStore({
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
type ChatHandle = ReturnType<typeof chatStore>
|
||||
type ChatHandle = ReturnType<typeof _chatStore>
|
||||
|
||||
type FrameProps =
|
||||
& PropsRuntime<'chain.frame'>
|
||||
@@ -115,7 +115,7 @@ describe('terminal-design type chain', () => {
|
||||
// member payloads are the runtime merge's property — not probed here
|
||||
// (the runtime package's own tests cover them).
|
||||
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
|
||||
const draft: string = cp.useStore((s) => s.draft)
|
||||
const draft: string = cp.useStore(s => s.draft)
|
||||
cp.actions.select({ id: 'm1' })
|
||||
void draft
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('terminal-design type chain', () => {
|
||||
// chain position.
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
|
||||
priority: 1,
|
||||
}, Takeover)
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('terminal-design type chain', () => {
|
||||
// checks through parameter contravariance.
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
select: ({ items }) => items.find(i => i.kind === 'q') ?? null,
|
||||
}, WideTakeover)
|
||||
|
||||
// renderSlotChain share: chain keys dispatch with the fallback bag;
|
||||
@@ -179,7 +179,7 @@ describe('terminal-design type chain', () => {
|
||||
name: 'chain.side',
|
||||
// @ts-expect-error root-scope inject has no sessionId parameter
|
||||
inject: (sessionId: string) => ({ x: sessionId }),
|
||||
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
|
||||
}, (_p => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
|
||||
|
||||
// keyed registration without key.
|
||||
// @ts-expect-error keyed registration requires options.key
|
||||
@@ -195,14 +195,14 @@ describe('terminal-design type chain', () => {
|
||||
// @ts-expect-error component matched prop drifts from the select return
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q') ?? null,
|
||||
}, NarrowTakeover)
|
||||
|
||||
// select must return M | null, not undefined (find() must be coalesced).
|
||||
// @ts-expect-error select may not return undefined
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'),
|
||||
select: ({ items }: { items: readonly Item[] }) => items.find(i => i.kind === 'q'),
|
||||
}, Takeover)
|
||||
|
||||
// Chain keys are not renderSlot-dispatchable (and vice versa).
|
||||
@@ -211,7 +211,7 @@ describe('terminal-design type chain', () => {
|
||||
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
|
||||
chainSlots.renderSlotChain('chain.conv', {})
|
||||
// @ts-expect-error a children set without chain keys provides no renderSlotChain
|
||||
fp.renderSlotChain
|
||||
type _NoChainSeat = typeof fp.renderSlotChain
|
||||
|
||||
// renderSlot owner share typed at the call site.
|
||||
// @ts-expect-error owner shape mismatch (width missing)
|
||||
|
||||
@@ -16,11 +16,11 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
subtool: 'Sub',
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||
user: css.tagUser!,
|
||||
message: css.tagMessage!,
|
||||
tool: css.tagTool!,
|
||||
subtool: css.tagSubtool!,
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
|
||||
user: css.tagUser,
|
||||
message: css.tagMessage,
|
||||
tool: css.tagTool,
|
||||
subtool: css.tagSubtool,
|
||||
}
|
||||
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
@@ -84,7 +84,7 @@ export function TrajectoryCell({
|
||||
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
|
||||
<span className={css.index}>#{index}</span>
|
||||
<span className={css.tagSlot}>
|
||||
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
|
||||
<span className={[css.tag, TAG_CLASS[kind]].filter((c): c is string => c !== undefined).join(' ')}>{KIND_LABEL[kind]}</span>
|
||||
</span>
|
||||
<span className={css.text}>{text}</span>
|
||||
<span className={css.trailing}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import css from './TrajectoryStatsHeader.module.css'
|
||||
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
|
||||
if (stats.turns === 0) return null
|
||||
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
|
||||
<div className={css.inner}>
|
||||
<span className={css.title}>Turn {turn}</span>
|
||||
<div className={css.columns} aria-hidden="true">
|
||||
{COLUMN_LABELS.map((label) => (
|
||||
{COLUMN_LABELS.map(label => (
|
||||
<span key={label} className={css.column}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,10 @@ import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const partial = useSession((s) => s.partial)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
|
||||
[nodes, partial, runningCalls, codeDispatches],
|
||||
@@ -22,15 +22,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
}
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{turns.map((turn) => (
|
||||
{turns.map(turn => (
|
||||
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
||||
{turn.groups.flatMap((group) => [
|
||||
{turn.groups.flatMap(group => [
|
||||
<TrajectoryGroupHeader
|
||||
key={`${group.title}-h`}
|
||||
title={group.title}
|
||||
{...(group.description !== undefined ? { description: group.description } : {})}
|
||||
/>,
|
||||
...group.cells.map((cell) => (
|
||||
...group.cells.map(cell => (
|
||||
<TrajectoryCell key={cell.index} {...cell} />
|
||||
)),
|
||||
])}
|
||||
|
||||
@@ -24,8 +24,8 @@ export interface WaterfallExtraProps {
|
||||
|
||||
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
||||
const scale = pxPerNode ?? PX_PER_NODE
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
|
||||
@@ -50,7 +50,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(subSpans.get(span.turn) ?? []).map((lane) => (
|
||||
{(subSpans.get(span.turn) ?? []).map(lane => (
|
||||
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
|
||||
<span className={css.subTag}>{lane.name}</span>
|
||||
<span
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
|
||||
expect(screen.getByText('381')).toBeTruthy()
|
||||
expect(screen.getByText('155')).toBeTruthy()
|
||||
expect(screen.getByText('+235.2s')).toBeTruthy()
|
||||
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
|
||||
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
|
||||
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
|
||||
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
|
||||
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
|
||||
|
||||
@@ -73,13 +73,13 @@ describe('deriveTrajectoryLayout', () => {
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(turns[0]?.turn).toBe(1)
|
||||
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
|
||||
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
|
||||
expect(kinds).toEqual(['user', 'message', 'tool'])
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
input: 10, output: 20, think: 5, timeSeconds: 5,
|
||||
})
|
||||
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
|
||||
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
|
||||
expect(tool?.text).toBe('bash · {"command":"ls"}')
|
||||
expect(tool?.timeSeconds).toBe(1.3)
|
||||
})
|
||||
@@ -87,14 +87,14 @@ describe('deriveTrajectoryLayout', () => {
|
||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
||||
nodes: [],
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
turn: 1, step: 2, time: 9_000, callView: null,
|
||||
}],
|
||||
})
|
||||
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
|
||||
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
|
||||
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
|
||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||
})
|
||||
@@ -113,9 +113,9 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
|
||||
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
|
||||
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a wall-span step description with a tool histogram', () => {
|
||||
@@ -156,9 +156,9 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns.map((t) => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
||||
expect(turns.map(t => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
|
||||
})
|
||||
|
||||
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
||||
@@ -170,7 +170,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
})
|
||||
@@ -199,8 +199,8 @@ describe('deriveTrajectoryLayout', () => {
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups
|
||||
.flatMap((g) => g.cells)
|
||||
.find((c) => c.kind === 'message' && c.text === 'done')
|
||||
.flatMap(g => g.cells)
|
||||
.find(c => c.kind === 'message' && c.text === 'done')
|
||||
// From context at 9s, not from the earlier user/tool surfaces.
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
})
|
||||
@@ -234,10 +234,10 @@ describe('run_code sub-dispatch cells', () => {
|
||||
settledSub(2, 'read', 7_300, 7_800),
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]!.groups.flatMap((g) => g.cells)
|
||||
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||
const cells = turns[0]!.groups.flatMap(g => g.cells)
|
||||
expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||
// Sequential indexes across the interleave; durations from the pair times.
|
||||
expect(cells.map((c) => c.index)).toEqual([1, 2, 3])
|
||||
expect(cells.map(c => c.index)).toEqual([1, 2, 3])
|
||||
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
||||
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
|
||||
})
|
||||
@@ -249,7 +249,7 @@ describe('run_code sub-dispatch cells', () => {
|
||||
}
|
||||
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool')
|
||||
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
|
||||
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}}
|
||||
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
|
||||
inputActions={{ setDraft: vi.fn(), submit: vi.fn() } as never}
|
||||
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
|
||||
bindDraftMirror={() => () => {}}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
@@ -164,7 +164,7 @@ describe('plugin registration', () => {
|
||||
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.dispose()
|
||||
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
|
||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
@@ -198,7 +198,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
|
||||
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
|
||||
mount(b.slots, [])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
@@ -221,12 +221,12 @@ describe('span derivation', () => {
|
||||
})
|
||||
|
||||
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
|
||||
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
|
||||
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
|
||||
expect(container.firstChild).toBeNull()
|
||||
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -234,7 +234,7 @@ describe('span derivation', () => {
|
||||
describe('WaterfallView standalone branches', () => {
|
||||
it('empty window renders the placeholder copy', () => {
|
||||
render(createElement(WaterfallView as FC<ConvViewProps>,
|
||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('WaterfallView standalone branches', () => {
|
||||
|
||||
describe('node half', () => {
|
||||
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -295,7 +295,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
|
||||
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
|
||||
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
|
||||
// Extends from its start to the window end.
|
||||
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
||||
|
||||
@@ -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: b5a78c30ddae5e12612bb8cced65b5fe95f7e259
|
||||
README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951
|
||||
README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9
|
||||
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
|
||||
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **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.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
@@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
|
||||
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。
|
||||
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。
|
||||
|
||||
@@ -21,7 +21,10 @@ import { ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
|
||||
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
|
||||
import css from './WorkspaceBrowser.module.css'
|
||||
|
||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
||||
/**
|
||||
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
|
||||
* focus() forces a synchronous layout and would jank the slide.
|
||||
*/
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
@@ -32,7 +35,7 @@ const GROUP_BY_ITEMS = [
|
||||
|
||||
/** Immutable membership toggle for the local expansion arrays. */
|
||||
function toggled(list: readonly string[], key: string): string[] {
|
||||
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
|
||||
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
||||
}
|
||||
|
||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||
@@ -61,7 +64,7 @@ function GroupByMenu({ groupBy, onPick }: {
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setOpen((v) => !v) }}
|
||||
onClick={() => { setOpen(v => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
@@ -96,7 +99,7 @@ function SessionTree({
|
||||
useSessions, startSession, open, workspaces, query,
|
||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
@@ -108,7 +111,7 @@ function SessionTree({
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
@@ -129,22 +132,22 @@ function SessionTree({
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
|
||||
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
actions={group.workspaceId === undefined
|
||||
? undefined
|
||||
: {
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace group roots outside search. The drag
|
||||
@@ -189,7 +192,7 @@ function SessionTree({
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
)
|
||||
@@ -204,7 +207,7 @@ function SessionTree({
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
|
||||
const list = useSessions((s) => s)
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||
const now = Date.now()
|
||||
return (
|
||||
@@ -250,6 +253,7 @@ export function WorkspaceBrowser({
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
@@ -291,7 +295,7 @@ export function WorkspaceBrowser({
|
||||
setRenameError(null)
|
||||
}
|
||||
const confirmRename = () => {
|
||||
if (renameBlocked || renameTarget === null) return
|
||||
if (renameBlocked) return
|
||||
setRenaming(true)
|
||||
setRenameError(null)
|
||||
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
|
||||
@@ -367,6 +371,7 @@ export function WorkspaceBrowser({
|
||||
anchorRef={wsPlusRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
onPick={(workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
@@ -417,24 +422,24 @@ export function WorkspaceBrowser({
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} query={query} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
onRenameRequest={(workspaceId, currentTitle) => {
|
||||
setRenameTarget({ workspaceId, currentTitle })
|
||||
setRenameDraft(currentTitle)
|
||||
setRenameError(null)
|
||||
}}
|
||||
onDeleteRequest={(workspaceId, title) => {
|
||||
setDeleteTarget({ workspaceId, title })
|
||||
setDeleteError(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@@ -479,7 +484,7 @@ export function WorkspaceBrowser({
|
||||
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteAction!}
|
||||
className={css.deleteAction}
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
|
||||
@@ -9,15 +9,17 @@ import { useCallback, useState } from 'react'
|
||||
import {
|
||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WorkspaceCreateError,
|
||||
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspacePickerProps } from './contract/slots.ts'
|
||||
import css from './WorkspacePicker.module.css'
|
||||
|
||||
const CREATE_WORKSPACE = '::create-workspace'
|
||||
const USE_EXISTING = '::use-existing'
|
||||
const OPEN_LOCAL_FOLDER = '::open-local-folder'
|
||||
const CREATE_NEW = '::create-new'
|
||||
|
||||
type ModalKind = 'path' | 'create' | null
|
||||
type ModalKind = 'create' | 'folder-error' | null
|
||||
|
||||
/** Core flow props: the owner supplies popover control and pick semantics. */
|
||||
export interface WorkspaceCreateFlowProps {
|
||||
@@ -29,6 +31,8 @@ export interface WorkspaceCreateFlowProps {
|
||||
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
|
||||
/** Create or adopt a real Host Workspace. */
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Open the Host's native single-directory picker. */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
/** A real Workspace was picked or created. */
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
/** Close the popover (outside click / Escape / post-pick). */
|
||||
@@ -45,6 +49,7 @@ export function WorkspaceCreateFlow({
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
onPick,
|
||||
onClose,
|
||||
}: WorkspaceCreateFlowProps) {
|
||||
@@ -55,30 +60,25 @@ export function WorkspaceCreateFlow({
|
||||
[anchorRef],
|
||||
)
|
||||
const [modalKind, setModalKind] = useState<ModalKind>(null)
|
||||
const [pathDraft, setPathDraft] = useState('')
|
||||
const [workspaceName, setWorkspaceName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [pickingFolder, setPickingFolder] = useState(false)
|
||||
const [folderConflict, setFolderConflict] = useState(false)
|
||||
const normalizedWorkspaceName = workspaceName.trim()
|
||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
id: workspace.workspaceId as string,
|
||||
id: workspace.workspaceId,
|
||||
label: workspace.title,
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
disabled: pickingFolder,
|
||||
})),
|
||||
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
|
||||
{
|
||||
id: CREATE_WORKSPACE,
|
||||
label: 'Create workspace',
|
||||
icon: <IconPlusOutline16 size={16} />,
|
||||
submenu: [
|
||||
{ id: USE_EXISTING, label: 'Use an existing folder' },
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace' },
|
||||
],
|
||||
},
|
||||
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
|
||||
]
|
||||
|
||||
const closeModal = (): void => {
|
||||
@@ -87,12 +87,29 @@ export function WorkspaceCreateFlow({
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
const openLocalFolder = (): void => {
|
||||
onClose()
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
setFolderConflict(false)
|
||||
setPickingFolder(true)
|
||||
void pickDirectory().then(async (path) => {
|
||||
if (path === null) return
|
||||
const workspace = await createWorkspace({ path })
|
||||
onPick(workspace.workspaceId)
|
||||
}).catch((reason: unknown) => {
|
||||
setFolderConflict(
|
||||
reason instanceof WorkspaceCreateError
|
||||
&& reason.rpcError.code === 'workspace-name-conflict',
|
||||
)
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setModalKind('folder-error')
|
||||
}).finally(() => { setPickingFolder(false) })
|
||||
}
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
if (id === USE_EXISTING) {
|
||||
onClose()
|
||||
setPathDraft('')
|
||||
setModalError(null)
|
||||
setModalKind('path')
|
||||
if (id === OPEN_LOCAL_FOLDER) {
|
||||
openLocalFolder()
|
||||
return
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
@@ -120,11 +137,6 @@ export function WorkspaceCreateFlow({
|
||||
})
|
||||
}
|
||||
|
||||
const confirmPath = (): void => {
|
||||
const path = pathDraft.trim()
|
||||
if (path !== '') create({ path })
|
||||
}
|
||||
|
||||
const confirmCreate = (): void => {
|
||||
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
|
||||
create({ name: normalizedWorkspaceName })
|
||||
@@ -144,40 +156,21 @@ export function WorkspaceCreateFlow({
|
||||
/>
|
||||
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces…</div>}
|
||||
<Modal
|
||||
open={modalKind === 'path'}
|
||||
open={modalKind === 'folder-error'}
|
||||
onClose={closeModal}
|
||||
title="Use an existing folder"
|
||||
title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t open folder'}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={creating || pathDraft.trim() === ''}
|
||||
onClick={confirmPath}
|
||||
>
|
||||
Use folder
|
||||
</Button>
|
||||
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
|
||||
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={pathDraft}
|
||||
aria-label="Existing folder path"
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
placeholder="/path/to/project"
|
||||
onChange={(event) => { setPathDraft(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
confirmPath()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{creating && <div className={css.modalStatus} role="status">Creating workspace…</div>}
|
||||
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
|
||||
<div className={css.modalError} role="alert">
|
||||
{folderConflict
|
||||
? 'Choose a folder with a different name.'
|
||||
: modalError}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={modalKind === 'create'}
|
||||
@@ -186,10 +179,10 @@ export function WorkspaceCreateFlow({
|
||||
description="The name is used for both the workspace and its new folder."
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
className={css.modalAction}
|
||||
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
@@ -235,6 +228,7 @@ export function WorkspacePicker({
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
}: WorkspacePickerProps) {
|
||||
return (
|
||||
<WorkspaceCreateFlow
|
||||
@@ -242,6 +236,7 @@ export function WorkspacePicker({
|
||||
anchorRef={anchorRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,8 @@ export type WorkspaceBrowserInjected = {
|
||||
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
|
||||
/** Explicitly create or adopt a real Workspace before targeting a Session. */
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Ask the local Host to open its native single-directory picker. */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/** Full browser props: shell owner share + viewing store + injected actions. */
|
||||
@@ -57,7 +59,9 @@ export type WorkspaceBrowserProps =
|
||||
*/
|
||||
export type WorkspacePickerInjected = {
|
||||
/** Explicitly create or adopt a real Workspace before targeting a Session. */
|
||||
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Ask the local Host to open its native single-directory picker. */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,9 +44,11 @@ export function apply(ctx: ClientContext): void {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
})
|
||||
// Declaration-aware registration: each owner's declaring apply may activate
|
||||
// after this one (entry activation order is unconstrained), and a register
|
||||
|
||||
@@ -192,37 +192,37 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
||||
onDragStart={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
drag.start()
|
||||
}}
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
drag.start()
|
||||
}}
|
||||
onDragEnd={drag?.end}
|
||||
onDragOver={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
drag.hover(rowHalf(e))
|
||||
}}
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
drag.hover(rowHalf(e))
|
||||
}}
|
||||
onDrop={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
drag.drop(rowHalf(e))
|
||||
}}
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
drag.drop(rowHalf(e))
|
||||
}}
|
||||
>
|
||||
{row.hasChildren && !flat
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.title}>{row.title}</span>
|
||||
|
||||
@@ -14,16 +14,17 @@ async function bench() {
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
const pickDirectory = vi.fn(async () => '/tmp/picked')
|
||||
const startSession = vi.fn()
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
const clear = vi.fn()
|
||||
ctx.provide('workspaces', {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
create, pickDirectory, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear }
|
||||
}
|
||||
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||
@@ -72,10 +73,14 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
|
||||
await browser.createWorkspace({ name: 'project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
await browser.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
||||
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
await picker.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await picker.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('unregisters every entry on teardown', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
|
||||
row.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
|
||||
x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
}
|
||||
|
||||
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
|
||||
|
||||
@@ -32,7 +32,9 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
|
||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||
@@ -57,6 +59,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
@@ -296,7 +299,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
// Drop on the top half of "three": insert one before three.
|
||||
@@ -309,7 +312,7 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
one.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
fireDrag(one, 'dragOver', 105)
|
||||
fireDrag(one, 'drop', 105)
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
@@ -335,7 +338,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
fireDrag(two, 'drop', 155)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
|
||||
})
|
||||
@@ -352,7 +355,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireEvent.dragEnd(one)
|
||||
@@ -381,7 +384,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'drop', 180)
|
||||
@@ -403,13 +406,13 @@ describe('WorkspaceBrowser', () => {
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
expect(input.value).toBe('Alpha')
|
||||
// Unchanged and blank names stay blocked.
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
// A duplicate of another workspace's title shows the inline conflict.
|
||||
fireEvent.change(input, { target: { value: ' Beta ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
||||
@@ -472,13 +475,13 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
||||
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
||||
|
||||
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
|
||||
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -15,7 +16,9 @@ function workspace(id: string, title = id): WorkspaceView {
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}
|
||||
@@ -32,7 +35,11 @@ function anchor(): { current: HTMLElement } {
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
|
||||
function mount(
|
||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
createWorkspace = vi.fn(),
|
||||
pickDirectory = vi.fn(async () => null as string | null),
|
||||
) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const anchorRef = anchor()
|
||||
@@ -45,20 +52,19 @@ function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace,
|
||||
view, onPick, onClose, createWorkspace, pickDirectory,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
|
||||
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
|
||||
const parent = screen.getByRole('menuitem', { name: 'Create workspace' })
|
||||
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
|
||||
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
@@ -73,7 +79,7 @@ describe('WorkspacePicker', () => {
|
||||
const created = workspace('new', 'New')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.change(input, { target: { value: 'project-one' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
@@ -81,34 +87,81 @@ describe('WorkspacePicker', () => {
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('adopts an existing path through the same immediate create action', async () => {
|
||||
const created = workspace('adopted')
|
||||
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
|
||||
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Use an existing folder')
|
||||
const input = screen.getByLabelText('Existing folder path')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(createWorkspace).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: ' /tmp/project ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
const pickDirectory = vi.fn(async () => '/tmp/project')
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
chooseItem('Open local folder…')
|
||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('treats native picker cancellation as a silent no-op', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => null))
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a name conflict and retries through the native picker', async () => {
|
||||
const pickDirectory = vi.fn()
|
||||
.mockResolvedValueOnce('/one/project')
|
||||
.mockResolvedValueOnce(null)
|
||||
const createWorkspace = vi.fn(async () => {
|
||||
throw new WorkspaceCreateError({
|
||||
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the folder action while the native picker is already open', async () => {
|
||||
let resolve!: (path: string | null) => void
|
||||
const pending = new Promise<string | null>((settle) => { resolve = settle })
|
||||
const b = mount([], vi.fn(), vi.fn(() => pending))
|
||||
chooseItem('Open local folder…')
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { resolve(null); await pending })
|
||||
})
|
||||
|
||||
it('reports non-Error native picker failures', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||
})
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a creation modal when the user cancels', () => {
|
||||
mount([])
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks a create-new name already present in the Workspace list', () => {
|
||||
const b = mount([workspace('alpha', 'Alpha')])
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -118,7 +171,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('fresh', 'same-name')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
@@ -134,7 +187,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
const createWorkspace = vi.fn(() => pending)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.change(input, { target: { value: 'broken' } })
|
||||
@@ -151,7 +204,7 @@ describe('WorkspacePicker', () => {
|
||||
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||
@@ -163,7 +216,7 @@ describe('WorkspacePicker', () => {
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -176,7 +229,7 @@ describe('WorkspacePicker', () => {
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
|
||||
@@ -200,7 +200,8 @@ function standardKit(
|
||||
scope: SlotScope,
|
||||
info: SessionMaybeProvideInfo | undefined,
|
||||
): {
|
||||
kit: InjectedProps; actions: object | undefined
|
||||
kit: InjectedProps
|
||||
actions: object | undefined
|
||||
} {
|
||||
const kit: InjectedProps = {
|
||||
useSessions: observableHook(host.sessions.list),
|
||||
@@ -232,13 +233,13 @@ function standardKit(
|
||||
kit['renderSlot'] = boundRenderSlot(host, entry)
|
||||
// renderSlotChain rides the same declaration source: only entries whose
|
||||
// children include a chain-kind slot receive the chain dispatch seat.
|
||||
if (Object.values(entry.children).some((spec) => spec.kind === 'chain')) {
|
||||
if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
|
||||
kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
|
||||
}
|
||||
// SessionProvider standard seat: entries declaring a session-scope child
|
||||
// render the session area, so the framework hands them the self-wired
|
||||
// provider (module-level component = stable reference; no value import).
|
||||
if (Object.values(entry.children).some((spec) => spec.scope === 'session')) {
|
||||
if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
|
||||
kit['SessionProvider'] = SessionProvider
|
||||
}
|
||||
}
|
||||
@@ -253,7 +254,9 @@ function standardKit(
|
||||
* composition point, one per scope branch).
|
||||
*/
|
||||
function SessionEntry({ entry, ownerProps, info }: {
|
||||
entry: StoredEntry; ownerProps: object; info: SessionProvideInfo
|
||||
entry: StoredEntry
|
||||
ownerProps: object
|
||||
info: SessionProvideInfo
|
||||
}) {
|
||||
const host = useHost()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
@@ -280,7 +283,9 @@ function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: obje
|
||||
}
|
||||
|
||||
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
||||
slotKey: string; entry: StoredEntry; ownerProps: object
|
||||
slotKey: string
|
||||
entry: StoredEntry
|
||||
ownerProps: object
|
||||
}) {
|
||||
const info = useSessionMaybeProvideInfo()
|
||||
if (info.sessionId === undefined) return null
|
||||
@@ -292,12 +297,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
||||
}
|
||||
|
||||
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
slotKey: string; ownerProps: object; opts?: (RenderOpts & ChainRenderOpts) | undefined
|
||||
slotKey: string
|
||||
ownerProps: object
|
||||
opts?: (RenderOpts & ChainRenderOpts) | undefined
|
||||
}) {
|
||||
const host = useHost()
|
||||
// Version tick drives entries() re-read; the host batches per microtask.
|
||||
useSyncExternalStore(
|
||||
(fn) => host.subscribe(slotKey, fn),
|
||||
fn => host.subscribe(slotKey, fn),
|
||||
() => host.getVersion(slotKey),
|
||||
)
|
||||
const sessionInfo = useSessionMaybeProvideInfo()
|
||||
@@ -321,12 +328,12 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
spec.scope === 'session'
|
||||
? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
|
||||
: (
|
||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||
{spec.scope === 'session-maybe'
|
||||
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
||||
: <RootEntry entry={entry} ownerProps={owner} />}
|
||||
</SlotErrorBoundary>
|
||||
)
|
||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||
{spec.scope === 'session-maybe'
|
||||
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
||||
: <RootEntry entry={entry} ownerProps={owner} />}
|
||||
</SlotErrorBoundary>
|
||||
)
|
||||
)
|
||||
|
||||
if (spec.kind === 'single') {
|
||||
@@ -335,7 +342,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
return guarded(entry)
|
||||
}
|
||||
if (spec.kind === 'keyed') {
|
||||
const entry = entries.find((e) => e.options?.key === opts?.entryKey)
|
||||
const entry = entries.find(e => e.options.key === opts?.entryKey)
|
||||
if (!entry) return <>{opts?.fallback ?? null}</>
|
||||
return guarded(entry)
|
||||
}
|
||||
@@ -388,13 +395,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
return elected ?? <>{opts?.fallback ?? null}</>
|
||||
}
|
||||
// list: registration order refined by explicit order, optional id filter.
|
||||
const withListOptions = entries.map((entry) => ({
|
||||
const withListOptions = entries.map(entry => ({
|
||||
entry,
|
||||
id: entry.options?.id,
|
||||
order: entry.options?.order ?? 0,
|
||||
id: entry.options.id,
|
||||
order: entry.options.order ?? 0,
|
||||
}))
|
||||
let list = [...withListOptions].sort((a, b) => a.order - b.order)
|
||||
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only)
|
||||
if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
|
||||
if (list.length === 0) return <>{opts?.fallback ?? null}</>
|
||||
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
|
||||
}
|
||||
@@ -403,7 +410,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||
function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
||||
const host = useHost()
|
||||
useSyncExternalStore(
|
||||
(fn) => host.subscribe('root', fn),
|
||||
fn => host.subscribe('root', fn),
|
||||
() => host.getVersion('root'),
|
||||
)
|
||||
const entry = host.entriesOf('root')[0]
|
||||
|
||||
@@ -73,11 +73,14 @@ const absentSource: HostObservable<undefined> = {
|
||||
/** Bind a source that disappears with the current session to an optional selector hook. */
|
||||
export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
|
||||
if (source !== undefined) return observableHook(source)
|
||||
return useAbsentSnapshot as MaybeSnapshotSelectorHook<T>
|
||||
return useAbsentSnapshot
|
||||
}
|
||||
|
||||
function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
|
||||
return observableHook(absentSource)(() => undefined)
|
||||
// The uSES subscription must still run (hook-order stability); the absent
|
||||
// source always snapshots undefined, returned explicitly.
|
||||
observableHook(absentSource)(() => undefined)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +90,7 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
|
||||
*/
|
||||
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)((s) => s)
|
||||
const id = observableHook(host.sessions.current)(s => s)
|
||||
return (
|
||||
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
|
||||
{children}
|
||||
@@ -112,7 +115,7 @@ export interface SessionProviderProps {
|
||||
*/
|
||||
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)((s) => s)
|
||||
const id = observableHook(host.sessions.current)(s => s)
|
||||
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
|
||||
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from
|
||||
// Keep equality local: this suite asserts the eq parameter contract without
|
||||
// adding a reverse dependency from web-react to runtime.
|
||||
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
|
||||
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
|
||||
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => Object.is(a[k], b[k]))
|
||||
|
||||
interface Snap { a: number; b: number }
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('bindSnapshotSelector', () => {
|
||||
const { source, set } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||
expect(probe.value).toBe(1)
|
||||
const before = probe.renders
|
||||
act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail
|
||||
@@ -65,7 +65,7 @@ describe('bindSnapshotSelector', () => {
|
||||
const { source, set } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as { a: number } | undefined }
|
||||
render(<Harness useSelector={useSelector} sel={(s) => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
|
||||
render(<Harness useSelector={useSelector} sel={s => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
|
||||
const before = probe.renders
|
||||
act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
|
||||
expect(probe.renders).toBe(before)
|
||||
@@ -78,11 +78,11 @@ describe('bindSnapshotSelector', () => {
|
||||
const { source, set, stats } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
const { rerender } = render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
const { rerender } = render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||
const after = stats.subscribeCalls
|
||||
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||
act(() => { set({ a: 2, b: 10 }) })
|
||||
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||
expect(stats.subscribeCalls).toBe(after)
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('bindSnapshotSelector', () => {
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
const view = render(
|
||||
<StrictMode>
|
||||
<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />
|
||||
<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />
|
||||
</StrictMode>,
|
||||
)
|
||||
expect(probe.value).toBe(1)
|
||||
@@ -112,7 +112,7 @@ describe('bindSnapshotSelector', () => {
|
||||
}
|
||||
const useSelector = bindSnapshotSelector(new MethodSource())
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
|
||||
expect(probe.value).toBe(7)
|
||||
})
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
|
||||
function hostOver(core: SlotCore): SlotRendererHost {
|
||||
return {
|
||||
subscribe: (key, fn) => core.subscribe(key, fn),
|
||||
getVersion: (key) => core.getVersion(key),
|
||||
entriesOf: (key) => core.entries(key),
|
||||
specOf: (key) => core.specDynamic(key),
|
||||
isLive: (entry) => core.isLive(entry),
|
||||
getVersion: key => core.getVersion(key),
|
||||
entriesOf: key => core.entries(key),
|
||||
specOf: key => core.specDynamic(key),
|
||||
isLive: entry => core.isLive(entry),
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
@@ -61,7 +61,7 @@ function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot'])
|
||||
describe('createSlotRenderer over the real SlotCore', () => {
|
||||
it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
|
||||
const core = new SlotCore()
|
||||
const { view } = mountFrame(core, (renderSlot) =>
|
||||
const { view } = mountFrame(core, renderSlot =>
|
||||
renderSlot('spec.single', {}, { fallback: <i>none</i> }))
|
||||
expect(view.container.textContent).toBe('none')
|
||||
let dispose = () => {}
|
||||
@@ -78,7 +78,7 @@ describe('createSlotRenderer over the real SlotCore', () => {
|
||||
const core = new SlotCore()
|
||||
const notified = vi.fn()
|
||||
core.subscribe('spec.list', notified)
|
||||
const { view } = mountFrame(core, (renderSlot) => renderSlot('spec.list', {}))
|
||||
const { view } = mountFrame(core, renderSlot => renderSlot('spec.list', {}))
|
||||
await act(async () => {
|
||||
core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
|
||||
core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)
|
||||
|
||||
@@ -33,7 +33,10 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
|
||||
* but entry.store is typed to the full contract — the real defineStore lives
|
||||
* in runtime, which web-react tests must not import (dependency direction).
|
||||
*/
|
||||
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
|
||||
function miniStore<T extends object>(
|
||||
init: () => T,
|
||||
mutators: Record<string, (state: T, ...params: never[]) => T>,
|
||||
): StoreHandle<T, ActionsDecl<T>> {
|
||||
return {
|
||||
spec: { init, actions: {} },
|
||||
create: () => {
|
||||
@@ -96,10 +99,10 @@ function makeHost() {
|
||||
subs.set(key, set)
|
||||
return () => { set.delete(fn) }
|
||||
},
|
||||
getVersion: (key) => versions.get(key) ?? 0,
|
||||
entriesOf: (key) => entries.get(key) ?? [],
|
||||
specOf: (key) => specs.get(key),
|
||||
isLive: (entry) => live.has(entry),
|
||||
getVersion: key => versions.get(key) ?? 0,
|
||||
entriesOf: key => entries.get(key) ?? [],
|
||||
specOf: key => specs.get(key),
|
||||
isLive: entry => live.has(entry),
|
||||
storeOf: (entry, scopeKey) => {
|
||||
if (entry.store === undefined) return undefined
|
||||
let perScope = storeCache.get(entry)
|
||||
@@ -121,8 +124,8 @@ function makeHost() {
|
||||
sessions: {
|
||||
list,
|
||||
current,
|
||||
provideInfo: (id) => infos.get(id),
|
||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: {}, props: {} },
|
||||
},
|
||||
workspaces: { list: workspaces },
|
||||
@@ -145,7 +148,7 @@ function makeHost() {
|
||||
live.add(entry)
|
||||
bump(key)
|
||||
return () => {
|
||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
||||
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
|
||||
live.delete(entry)
|
||||
bump(key)
|
||||
}
|
||||
@@ -187,7 +190,7 @@ const chainEntryOf = (partial: {
|
||||
priority?: number
|
||||
}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
|
||||
component: partial.component,
|
||||
select: partial.select as StoredEntry['select'],
|
||||
select: partial.select,
|
||||
...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
|
||||
})
|
||||
|
||||
@@ -230,7 +233,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
|
||||
(renderSlot) => renderSlot('k.single', {}, { fallback: <i>none</i> }))
|
||||
renderSlot => renderSlot('k.single', {}, { fallback: <i>none</i> }))
|
||||
expect(view.container.textContent).toBe('none')
|
||||
let dispose = () => {}
|
||||
act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
|
||||
@@ -242,7 +245,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
|
||||
const h = makeHost()
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
|
||||
(renderSlot) => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
|
||||
renderSlot => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
|
||||
// Declared by children (authorization) but absent from the ledger (specOf
|
||||
// undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
|
||||
expect(view.container.querySelector('main')!.textContent).toBe('')
|
||||
@@ -256,7 +259,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
|
||||
h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } })
|
||||
const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec }
|
||||
const { view } = mountRoot(h, children, (renderSlot) => <>
|
||||
const { view } = mountRoot(h, children, renderSlot => <>
|
||||
<main>{renderSlot('k.list', {})}</main>
|
||||
<aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
|
||||
<nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
|
||||
@@ -291,7 +294,7 @@ describe('child outlets and the renderSlot binding', () => {
|
||||
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
||||
(renderSlot) => renderSlot('k.list', {}))
|
||||
renderSlot => renderSlot('k.list', {}))
|
||||
spy.mockRestore()
|
||||
expect(view.container.textContent).toBe('alive')
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
@@ -309,10 +312,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
}))
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
|
||||
select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }),
|
||||
select: owner => ({ label: `hit:${(owner as { tag: string }).tag}` }),
|
||||
}))
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' }))
|
||||
renderSlotChain => renderSlotChain('k.chain', { tag: 'T' }))
|
||||
// The declining entry never mounts: the routing decision is select-layer only.
|
||||
expect(view.container.textContent).toBe('hit:T')
|
||||
expect(declinerBody).not.toHaveBeenCalled()
|
||||
@@ -327,10 +330,10 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
}))
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
|
||||
select: (owner) => (owner as { pick?: string }).pick ?? null,
|
||||
select: owner => (owner as { pick?: string }).pick ?? null,
|
||||
}))
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
|
||||
<main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
|
||||
<aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
|
||||
</>)
|
||||
@@ -347,16 +350,16 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => { throw new Error('entry A boom') },
|
||||
select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null,
|
||||
select: owner => (owner as { pick?: string }).pick === 'A' ? {} : null,
|
||||
}))
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <b>B-ok</b>,
|
||||
select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null,
|
||||
select: owner => (owner as { pick?: string }).pick === 'B' ? {} : null,
|
||||
}))
|
||||
let pick = 'A'
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { pick }))
|
||||
renderSlotChain => renderSlotChain('k.chain', { pick }))
|
||||
spy.mockRestore()
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
// Re-elect entry B: the entry-keyed boundary remounts fresh instead of
|
||||
@@ -372,9 +375,9 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
|
||||
select: (owner) => (owner as { pick?: string }).pick ?? null,
|
||||
select: owner => (owner as { pick?: string }).pick ?? null,
|
||||
}))
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
|
||||
<main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
|
||||
<aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
|
||||
</>)
|
||||
@@ -387,7 +390,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
|
||||
renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
|
||||
expect(view.container.textContent).toBe('none')
|
||||
let dispose = () => {}
|
||||
act(() => {
|
||||
@@ -422,7 +425,7 @@ describe('chain outlets and the renderSlotChain binding', () => {
|
||||
priority: 1,
|
||||
}))
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', {}))
|
||||
renderSlotChain => renderSlotChain('k.chain', {}))
|
||||
expect(view.container.textContent).toBe('early')
|
||||
})
|
||||
|
||||
@@ -490,13 +493,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <b>TAKEOVER</b>,
|
||||
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
|
||||
select: owner => (owner as { take?: boolean }).take ? {} : null,
|
||||
}))
|
||||
const mounted = vi.fn()
|
||||
const Probe = fallbackProbe(mounted)
|
||||
let take = false
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
|
||||
renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
|
||||
const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
|
||||
const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
|
||||
|
||||
@@ -525,13 +528,13 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
||||
h.declare('k.chain', CHAIN_ROOT)
|
||||
h.add('k.chain', chainEntryOf({
|
||||
component: () => <b>TAKEOVER</b>,
|
||||
select: (owner) => (owner as { take?: boolean }).take ? {} : null,
|
||||
select: owner => (owner as { take?: boolean }).take ? {} : null,
|
||||
}))
|
||||
const mounted = vi.fn()
|
||||
const Probe = fallbackProbe(mounted)
|
||||
let take = false
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
|
||||
renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
|
||||
fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } })
|
||||
expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
|
||||
|
||||
@@ -561,7 +564,7 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
|
||||
priority: 2,
|
||||
}))
|
||||
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
|
||||
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
|
||||
renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
|
||||
expect(view.container.textContent).toContain('ELECTED')
|
||||
expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
|
||||
spy.mockRestore()
|
||||
@@ -578,9 +581,9 @@ describe('standard-kit synthesis', () => {
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.add('k.single', {
|
||||
component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
|
||||
<b>{useSessions((s) => s.ids.length)}</b>,
|
||||
<b>{useSessions(s => s.ids.length)}</b>,
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||
expect(view.container.textContent).toBe('0')
|
||||
act(() => { h.list.set({ ids: ['a', 'b'] }) })
|
||||
expect(view.container.textContent).toBe('2')
|
||||
@@ -591,9 +594,9 @@ describe('standard-kit synthesis', () => {
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.add('k.single', {
|
||||
component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
|
||||
<b>{useWorkspaces((s) => s.ids.length)}</b>,
|
||||
<b>{useWorkspaces(s => s.ids.length)}</b>,
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||
expect(view.container.textContent).toBe('0')
|
||||
act(() => { h.workspaces.set({ ids: ['w1'] }) })
|
||||
expect(view.container.textContent).toBe('1')
|
||||
@@ -606,11 +609,11 @@ describe('standard-kit synthesis', () => {
|
||||
const seen: AnyProps[] = []
|
||||
h.add('k.session', {
|
||||
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
||||
seen.push({ ...props, read: props.useSession!((s) => s.sid) })
|
||||
seen.push({ ...props, read: props.useSession!(s => s.sid) })
|
||||
return null
|
||||
},
|
||||
})
|
||||
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
||||
mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
|
||||
<SessionProvider empty={() => <i>empty</i>}>
|
||||
{() => renderSlot('k.session', {})}
|
||||
</SessionProvider>
|
||||
@@ -669,14 +672,14 @@ describe('standard-kit synthesis', () => {
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
h.add('k.session', { component: () => <b>x</b> })
|
||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
|
||||
(renderSlot) => renderSlot('k.session', {}))
|
||||
renderSlot => renderSlot('k.session', {}))
|
||||
expect(view.container.querySelector('b')).toBeNull()
|
||||
})
|
||||
|
||||
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
|
||||
const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
|
||||
let bump = () => {}
|
||||
h.add('k.single', {
|
||||
component: ({ useStore, actions }: {
|
||||
@@ -684,11 +687,11 @@ describe('standard-kit synthesis', () => {
|
||||
actions: { inc: () => void }
|
||||
}) => {
|
||||
bump = actions.inc
|
||||
return <b>{useStore((s) => s.n)}</b>
|
||||
return <b>{useStore(s => s.n)}</b>
|
||||
},
|
||||
store: handle,
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||
expect(view.container.textContent).toBe('0')
|
||||
act(() => { bump() })
|
||||
expect(view.container.textContent).toBe('1')
|
||||
@@ -707,11 +710,11 @@ describe('standard-kit synthesis', () => {
|
||||
actions: { setDraft: (text: string) => void }
|
||||
}) => {
|
||||
setDraft = actions.setDraft
|
||||
return <b>{useStore((s) => s.draft) || '(blank)'}</b>
|
||||
return <b>{useStore(s => s.draft) || '(blank)'}</b>
|
||||
},
|
||||
store: handle,
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
|
||||
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
||||
))
|
||||
act(() => { h.current.set('s1') })
|
||||
@@ -730,7 +733,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const inject = vi.fn(() => ({ tag: 'FROM-INJECT' }))
|
||||
h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject })
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||
expect(view.container.textContent).toBe('FROM-INJECT')
|
||||
act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
@@ -745,9 +748,9 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
|
||||
h.add('k.session', {
|
||||
component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
|
||||
inject: inject as unknown as StoredEntry['inject'],
|
||||
inject: inject,
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
||||
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
|
||||
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
||||
))
|
||||
act(() => { h.current.set('s1') })
|
||||
@@ -767,22 +770,22 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
h.addSession('s1')
|
||||
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
|
||||
const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
|
||||
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
|
||||
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
|
||||
const seenRoot: AnyProps[] = []
|
||||
const seenSession: AnyProps[] = []
|
||||
h.add('k.single', {
|
||||
component: (props: object) => { seenRoot.push(props as AnyProps); return null },
|
||||
inject: rootInject as unknown as StoredEntry['inject'],
|
||||
inject: rootInject,
|
||||
store: handle,
|
||||
})
|
||||
h.add('k.session', {
|
||||
component: (props: object) => { seenSession.push(props as AnyProps); return null },
|
||||
inject: sessionInject as unknown as StoredEntry['inject'],
|
||||
inject: sessionInject,
|
||||
store: handle,
|
||||
})
|
||||
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot) => <>
|
||||
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <>
|
||||
{renderSlot('k.single', {})}
|
||||
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
|
||||
</>)
|
||||
@@ -807,7 +810,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
|
||||
(renderSlot) => <main>{renderSlot('k.list', {})}</main>)
|
||||
renderSlot => <main>{renderSlot('k.list', {})}</main>)
|
||||
spy.mockRestore()
|
||||
// The failing entry blacks out alone; the sibling and the tree above survive.
|
||||
expect(view.container.querySelector('main')).not.toBeNull()
|
||||
@@ -824,7 +827,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
inject: () => ({ fromInject: 'inject', shared: 'inject' }),
|
||||
})
|
||||
mountRoot(h, { 'k.single': SINGLE_ROOT },
|
||||
(renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
|
||||
renderSlot => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
|
||||
const props = seen.at(-1)!
|
||||
expect(typeof props['useSessions']).toBe('function') // kit always present
|
||||
expect(typeof props['useWorkspaces']).toBe('function')
|
||||
|
||||
@@ -43,15 +43,15 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
const host: SlotRendererHost = {
|
||||
subscribe: () => () => {},
|
||||
getVersion: () => 0,
|
||||
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
|
||||
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
|
||||
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||
isLive: () => true,
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: observable<unknown>({ ids: [] }),
|
||||
current,
|
||||
provideInfo: (id) => infos.get(id),
|
||||
maybeProvideInfo: (id) => (id === undefined ? undefined : infos.get(id))
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
@@ -78,7 +78,7 @@ describe('SessionProvider', () => {
|
||||
const h = makeHost({
|
||||
root: () => (
|
||||
<SessionProvider empty={() => <span>empty</span>}>
|
||||
{(id) => <div data-testid="body">{id}</div>}
|
||||
{id => <div data-testid="body">{id}</div>}
|
||||
</SessionProvider>
|
||||
),
|
||||
})
|
||||
@@ -93,7 +93,7 @@ describe('SessionProvider', () => {
|
||||
|
||||
it('renders null empty state when the empty prop is omitted', () => {
|
||||
const h = makeHost({
|
||||
root: () => <SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
|
||||
root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
|
||||
})
|
||||
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
expect(view.container.textContent).toBe('')
|
||||
@@ -110,7 +110,7 @@ describe('SessionProvider', () => {
|
||||
return <div>{id}</div>
|
||||
}
|
||||
const h = makeHost({
|
||||
root: () => <SessionProvider>{(id) => <Body id={id} />}</SessionProvider>,
|
||||
root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
|
||||
})
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
@@ -127,7 +127,7 @@ describe('SessionProvider', () => {
|
||||
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
const h = makeHost({
|
||||
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||
})
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
@@ -135,7 +135,7 @@ describe('SessionProvider', () => {
|
||||
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
||||
// The bound hook reads the cell's bare source — asserting through it
|
||||
// proves the machinery wired THIS session's source, not another's.
|
||||
seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) })
|
||||
seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
@@ -152,7 +152,7 @@ describe('SessionProvider', () => {
|
||||
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(
|
||||
<SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
|
||||
<SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
|
||||
)).toThrow(/outside the installed renderer tree/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -32,10 +32,10 @@ function makeHost() {
|
||||
subs.set(key, set)
|
||||
return () => { set.delete(fn) }
|
||||
},
|
||||
getVersion: (key) => versions.get(key) ?? 0,
|
||||
entriesOf: (key) => entries.get(key) ?? [],
|
||||
getVersion: key => versions.get(key) ?? 0,
|
||||
entriesOf: key => entries.get(key) ?? [],
|
||||
specOf: () => ({ kind: 'single', scope: 'root' }),
|
||||
isLive: (entry) => live.has(entry),
|
||||
isLive: entry => live.has(entry),
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
@@ -54,7 +54,7 @@ function makeHost() {
|
||||
live.add(entry)
|
||||
bump(key)
|
||||
return () => {
|
||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
||||
entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
|
||||
live.delete(entry)
|
||||
bump(key)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const newProbe = (): Probe => ({ invoke: () => {}, pending: false, renders: 0 })
|
||||
|
||||
describe('useInvoke', () => {
|
||||
it('tracks pending across the action lifecycle', async () => {
|
||||
const d = deferred<void>()
|
||||
const d = deferred()
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||
expect(probe.pending).toBe(false)
|
||||
@@ -39,8 +39,8 @@ describe('useInvoke', () => {
|
||||
})
|
||||
|
||||
it('keeps pending true until the last concurrent call settles', async () => {
|
||||
const d1 = deferred<void>()
|
||||
const d2 = deferred<void>()
|
||||
const d1 = deferred()
|
||||
const d2 = deferred()
|
||||
const queue = [d1, d2]
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
|
||||
@@ -68,7 +68,7 @@ describe('useInvoke', () => {
|
||||
|
||||
it('resets pending and logs when the action rejects', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const d = deferred<void>()
|
||||
const d = deferred()
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||
act(() => { probe.invoke() })
|
||||
|
||||
@@ -42,18 +42,18 @@ export function AppRoot(props: AppRootProps) {
|
||||
<div className={css.wordmark}>HARNESS</div>
|
||||
{!loud
|
||||
? (
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
<div className={css.hint}>Loading plugins…</div>
|
||||
</>
|
||||
)
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
<div className={css.hint}>Loading plugins…</div>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { DocumentTitle } from './DocumentTitle.tsx'
|
||||
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
||||
@@ -26,7 +25,7 @@ export interface AssemblyDeps {
|
||||
*/
|
||||
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
|
||||
const { ctx } = deps
|
||||
const sessions = ctx.get('sessions') as SessionsService | undefined
|
||||
const sessions = ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
|
||||
const useSessions = bindSnapshotSelector(sessions.list)
|
||||
const SessionDocumentTitle = (): ReactNode => {
|
||||
|
||||
@@ -150,8 +150,8 @@ export class AppWebEntry {
|
||||
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
|
||||
private async prefetchImmediateTier(): Promise<void> {
|
||||
await Promise.all(this.manifest.plugins
|
||||
.filter((row) => row.immediately)
|
||||
.map((row) => this.modules.prefetch(row.id).catch(() => {
|
||||
.filter(row => row.immediately)
|
||||
.map(row => this.modules.prefetch(row.id).catch(() => {
|
||||
// Import refetches and reports this loudly per entry; swallowing
|
||||
// here keeps one failing prefetch from masking the others.
|
||||
})))
|
||||
@@ -186,7 +186,7 @@ export class AppWebEntry {
|
||||
// its wrapper apply reads the kernel slot and provides ctx.modules (the
|
||||
// provide lives on the plugin face; see MODULES_ID for why the row loop
|
||||
// must then skip it).
|
||||
const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
|
||||
const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
|
||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// fetches parallelize. The app-shell assembly entry is appended by the
|
||||
@@ -225,7 +225,7 @@ export class AppWebEntry {
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
|
||||
@@ -46,13 +46,13 @@ export type LoaderStatus = Record<string, LoaderEntryState>
|
||||
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
||||
export interface KernelSignal<T> {
|
||||
/** Current value (stable reference between changes). */
|
||||
getSnapshot(): T
|
||||
getSnapshot: () => T
|
||||
/**
|
||||
* Subscribe to changes.
|
||||
* @param fn - change listener.
|
||||
* @returns the unsubscribe disposer.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
|
||||
/** Writable one-value signal (settled flag, boot failure report). */
|
||||
@@ -61,7 +61,7 @@ export interface KernelValueSignal<T> extends KernelSignal<T> {
|
||||
* Publish a new value and notify subscribers.
|
||||
* @param next - the new value.
|
||||
*/
|
||||
set(next: T): void
|
||||
set: (next: T) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
|
||||
* @param id - entry name.
|
||||
* @param state - projected fiber state.
|
||||
*/
|
||||
set(id: string, state: LoaderEntryState): void
|
||||
set: (id: string, state: LoaderEntryState) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -822,6 +822,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'telemetry',
|
||||
summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract emit(record: TelemetryRecord): void',
|
||||
jsDoc: '/**\n * See {@link TelemetryBackend.emit} — the seam declaration is the contract\'s one home.\n * @param record - the logical record to report; owned by the backend after the call.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'flush?(): void',
|
||||
jsDoc: '/** See {@link TelemetryBackend.flush}. */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract shutdown(): Promise<void>',
|
||||
jsDoc: '/**\n * See {@link TelemetryBackend.shutdown}.\n * @returns resolves when the backend\'s pipeline has quiesced.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tokenMeter',
|
||||
summary: 'Replay owner for one service-wide estimator and isolated per-session folds.',
|
||||
@@ -1261,6 +1279,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */',
|
||||
summary: 'Emitted when any prompt provider changes.',
|
||||
},
|
||||
{
|
||||
name: 'telemetry/record',
|
||||
mode: 'waterfall',
|
||||
signature: '\'telemetry/record\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord',
|
||||
jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */',
|
||||
summary: 'Transform one outbound record before it reaches the backend.',
|
||||
},
|
||||
{
|
||||
name: 'tools/change',
|
||||
mode: 'emit',
|
||||
@@ -1987,7 +2012,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'Session',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionAvailability',
|
||||
@@ -2377,6 +2402,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TaskStatus',
|
||||
declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
|
||||
},
|
||||
{
|
||||
name: 'TelemetryRecord',
|
||||
declaration: 'export interface TelemetryRecord {\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: TelemetrySeverity;\n attributes: Record<string, string | number>;\n body: unknown;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TelemetrySeverity',
|
||||
declaration: 'export type TelemetrySeverity = \'info\' | \'warn\' | \'error\';',
|
||||
},
|
||||
{
|
||||
name: 'TerminalCallView',
|
||||
declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}',
|
||||
|
||||
@@ -305,6 +305,19 @@ export class Session {
|
||||
return this.header.id
|
||||
}
|
||||
|
||||
/**
|
||||
* The first seq appended IN THIS PROCESS: the length of the constructor
|
||||
* seed (0 without one). Events below it entered through construction —
|
||||
* replay, fork, or resume — and were never published on the `session/event`
|
||||
* firehose (constructor seeds do not emit), so consumers that replay the
|
||||
* log as a publication substitute (telemetry adoption) start here. Distinct
|
||||
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
|
||||
* session's constructor seed is its full stored log, while its header keeps
|
||||
* the original fork value — this field is the in-process construction fact
|
||||
* and is deliberately not persisted.
|
||||
*/
|
||||
readonly firstLiveSeq: number
|
||||
|
||||
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
@@ -337,6 +350,7 @@ export class Session {
|
||||
this.log.push(deepFreeze(snapshot))
|
||||
}
|
||||
}
|
||||
this.firstLiveSeq = this.log.length
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1
|
||||
README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020
|
||||
README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86
|
||||
README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c
|
||||
|
||||
@@ -14,6 +14,8 @@ The mux stream projects the latest log-backed title as a validated `session/titl
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests.
|
||||
|
||||
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
@@ -35,3 +37,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser.
|
||||
|
||||
@@ -14,6 +14,8 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。
|
||||
|
||||
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
@@ -35,3 +37,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **Linux 原生选择器依赖桌面工具**:Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { pickNativeDirectory } from './native-directory-picker.ts'
|
||||
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
@@ -194,6 +195,8 @@ export interface ApiProxyDefaults {
|
||||
cwd: string
|
||||
/** Parent directory for name-created workspaces. */
|
||||
workspaceRoot: string
|
||||
/** Native single-directory picker; injectable for carrier tests. */
|
||||
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
|
||||
}
|
||||
|
||||
/** The tool/call payload fields the presenter path reads. */
|
||||
@@ -823,6 +826,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
attachedSessions: ctx.agents.list().length,
|
||||
}))
|
||||
},
|
||||
|
||||
async pickDirectory(request, signal) {
|
||||
try {
|
||||
const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal)
|
||||
return ok(request, { path })
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'directory picker was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
commands: {
|
||||
|
||||
@@ -17,3 +17,11 @@ export const hostDescribeValueSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
attachedSessions: z.number().int().nonnegative(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.pickDirectory'>>>
|
||||
|
||||
/** host.pickDirectory response value; null means the user cancelled. */
|
||||
export const hostPickDirectoryValueSchema = z.object({
|
||||
path: z.string().nullable(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
|
||||
|
||||
@@ -22,4 +22,10 @@ export interface HostApi {
|
||||
model?: string
|
||||
attachedSessions: number
|
||||
}>>
|
||||
|
||||
/** Open the operating system's single-directory picker; cancellation returns null. */
|
||||
pickDirectory(
|
||||
request: RpcRequest<{}>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ path: string | null }>>
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface RpcMethodMap {
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'host.describe': HostApi['describe']
|
||||
'host.pickDirectory': HostApi['pickDirectory']
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
|
||||
@@ -13,7 +13,7 @@ import { RpcId } from '../api/rpc.ts'
|
||||
import type { Wire } from '../api/rpc.schema.ts'
|
||||
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
|
||||
import { hostDescribeValueSchema } from '../api/host.schema.ts'
|
||||
import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
sessionCancelValueSchema,
|
||||
sessionCreateValueSchema,
|
||||
@@ -56,6 +56,7 @@ export interface IApiClient {
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
}
|
||||
workspace: {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
@@ -90,6 +91,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
@@ -177,13 +179,22 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
|
||||
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
|
||||
*/
|
||||
private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
|
||||
const timeout = AbortSignal.timeout(this.timeoutMs)
|
||||
private async postJson(
|
||||
path: string,
|
||||
body: ClientRequest | ClientResponse,
|
||||
signal: AbortSignal | undefined,
|
||||
useDefaultTimeout = true,
|
||||
): Promise<Response> {
|
||||
const requestSignal = useDefaultTimeout
|
||||
? signal === undefined
|
||||
? AbortSignal.timeout(this.timeoutMs)
|
||||
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
|
||||
: signal
|
||||
const response = await this.doFetch(new URL(path, this.resolveBase()), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
|
||||
...requestSignal === undefined ? {} : { signal: requestSignal },
|
||||
})
|
||||
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
|
||||
return response
|
||||
@@ -198,10 +209,11 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
useDefaultTimeout = true,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
|
||||
this.onEnvelope(message)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
this.onEnvelope(full)
|
||||
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
|
||||
@@ -282,6 +294,9 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
// longer than the normal unary deadline. Caller/connection aborts remain.
|
||||
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user