mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge origin/master (goal line #527) into web-permission-sandbox-merge-master
Shared-surface conflicts resolve as unions: the fixture serves all four projection keys (title/todos/permissions/goal) with both the /permission and /goal command mirrors (the goal-fixture placeholder retires with master), apps/cli carries both lines' dependency additions, and the README Model Experience allowlist keeps both entries. The connection specs assert the four-key baseline and the shifted approval/question replay indices; the module graph regenerates over the merged dependency set.
This commit is contained in:
@@ -12,6 +12,7 @@ export type {
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -351,6 +351,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
values['todos'] = backscanTodos(log) ?? null
|
||||
// Always present (permission service composed): the whole select.
|
||||
values['permissions'] = permissionSelectOf(log)
|
||||
// Always present (GoalService unit composed): null before create / after clear.
|
||||
values['goal'] = backscanGoal(log)
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -363,6 +365,14 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
if (!Object.hasOwn(values, 'title')) return []
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
|
||||
}
|
||||
// Goal fold: a round-zero goal-sourced user message advances the goal unit.
|
||||
if (type === 'user/message') {
|
||||
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
|
||||
if (source?.kind === 'goal' && source.round === 0) {
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
// Standing-plan fold: writes replace the list; turn/start clears it (null).
|
||||
if (type === 'todo/write' || type === 'turn/start') {
|
||||
return [{
|
||||
@@ -431,6 +441,55 @@ function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Fixture-local mirror of the goal projection value (dsh-goal's GoalProjection shape). */
|
||||
interface FxGoalProjection {
|
||||
goal: {
|
||||
id: string
|
||||
revision: number
|
||||
objective: string
|
||||
phase: 'active' | 'paused' | 'blocked' | 'complete'
|
||||
maxGoalRounds: number
|
||||
}
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One durable goal change riding a round-zero goal-sourced user message. */
|
||||
type FxGoalChange =
|
||||
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
|
||||
| {
|
||||
kind: 'goal/change'
|
||||
version: 1
|
||||
operation: 'create' | 'edit' | 'pause' | 'resume' | 'complete'
|
||||
goal: FxGoalProjection['goal']
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Current goal projection over the full log (host parallel: the GoalService
|
||||
* unit's last-wins fold of goal/change whole values; clear returns null).
|
||||
*/
|
||||
function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const event = log[i] as unknown as {
|
||||
type: string
|
||||
data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
|
||||
} | undefined
|
||||
if (event === undefined || event.type !== 'user/message') continue
|
||||
const source = event.data?.source
|
||||
if (source?.kind !== 'goal' || source.round !== 0) continue
|
||||
const change = source.change
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (change === undefined || change.kind !== 'goal/change') continue
|
||||
if (change.operation === 'clear') return null
|
||||
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface StreamConn<F> {
|
||||
push(envelope: RpcRequest<F>): void
|
||||
}
|
||||
@@ -625,6 +684,47 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
|
||||
}
|
||||
|
||||
/** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
|
||||
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
|
||||
const ref = change.operation === 'clear' ? change.cleared : change.goal
|
||||
const payload = change.operation === 'clear'
|
||||
? { cleared: change.cleared, clearedAt: change.clearedAt }
|
||||
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
append(id, {
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(
|
||||
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
|
||||
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
|
||||
),
|
||||
})
|
||||
return backscanGoal(logOf(id)) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
const fxMutateGoal = (
|
||||
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
|
||||
ref: { id: string; revision: number },
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
const goal = next(current)
|
||||
if (goal === undefined) {
|
||||
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1,
|
||||
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
|
||||
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
}
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
@@ -987,7 +1087,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
commands: [
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
|
||||
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
|
||||
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
|
||||
],
|
||||
})
|
||||
@@ -1024,10 +1124,32 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
}
|
||||
if (name === 'goal') {
|
||||
// Host parallel: /goal with an objective creates (or reports) the
|
||||
// current goal; the command lifecycle pair brackets the mutation.
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
|
||||
const objective = args.trim()
|
||||
const current = backscanGoal(logOf(id))
|
||||
let text: string
|
||||
if (objective === '') {
|
||||
text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
|
||||
} else if (current !== null && current.goal.phase !== 'complete') {
|
||||
text = `A goal already exists (${current.goal.objective}). Clear it first.`
|
||||
} else {
|
||||
const created = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
text = `Goal created: ${created.goal.objective}`
|
||||
}
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', text } })
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
}
|
||||
const outcomes: Record<string, string> = {
|
||||
compact: 'fixture:已压缩(假动作)',
|
||||
echo: args.trim(),
|
||||
'goal-fixture': `fixture:goal 已设置(${id})`,
|
||||
}
|
||||
const text = name === undefined ? undefined : outcomes[name]
|
||||
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
|
||||
@@ -1048,6 +1170,62 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
})
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
// Mutation-only mirror of the host handlers: each verb CAS-checks the
|
||||
// projected current goal, appends the whole-value change (the mux
|
||||
// stream and projection frame ride the shared append path), and
|
||||
// acknowledges with the new ref only.
|
||||
create: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
},
|
||||
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
pause: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
)),
|
||||
resume: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
)),
|
||||
complete: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
)),
|
||||
clear: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear',
|
||||
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { cleared: true as const })
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
const conn = new FxInbox<MuxFrame>()
|
||||
@@ -1193,6 +1371,12 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
case 'goal.pause': return this.api.goals.pause(request)
|
||||
case 'goal.resume': return this.api.goals.resume(request)
|
||||
case 'goal.complete': return this.api.goals.complete(request)
|
||||
case 'goal.clear': return this.api.goals.clear(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalRef,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
|
||||
@@ -127,6 +127,15 @@ export class FakeApiClient implements IApiClient {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture', 'permission'])
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
@@ -64,11 +64,11 @@ describe('createFixtureApi commands/skills', () => {
|
||||
|
||||
it('addresses execute to the session; an unknown session errs', async () => {
|
||||
const api = createFixtureApi()
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal)
|
||||
if (!hit.result.ok) throw new Error('execute failed')
|
||||
expect(hit.result.value.matched).toBe(true)
|
||||
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal)
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('createFixtureApi', () => {
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
},
|
||||
goal: null,
|
||||
} },
|
||||
})
|
||||
})
|
||||
@@ -216,7 +217,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 5) abort.abort()
|
||||
if (envelopes.length >= 7) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -224,14 +225,15 @@ describe('createFixtureApi', () => {
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + permissions units).
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + permissions + goal units).
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[5]?.rpcId).toBe(first[5]?.rpcId)
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[6]?.rpcId).toBe(first[6]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
@@ -747,6 +749,29 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
|
||||
if (!moved.result.ok) throw new Error('workspace move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
|
||||
// Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear;
|
||||
// every mutation acknowledges with the NEW CAS ref (state rides the projection frames).
|
||||
const goalCreated = await client.goals.create({ sessionId: id, objective: 'ship it' })
|
||||
if (!goalCreated.result.ok) throw new Error('goal create failed')
|
||||
let ref = goalCreated.result.value.ref
|
||||
expect(ref.revision).toBe(1)
|
||||
const edited = await client.goals.edit({ sessionId: id, ref, objective: 'ship it v2' })
|
||||
if (!edited.result.ok) throw new Error('goal edit failed')
|
||||
ref = edited.result.value.ref
|
||||
const paused = await client.goals.pause({ sessionId: id, ref })
|
||||
if (!paused.result.ok) throw new Error('goal pause failed')
|
||||
ref = paused.result.value.ref
|
||||
const resumed = await client.goals.resume({ sessionId: id, ref })
|
||||
if (!resumed.result.ok) throw new Error('goal resume failed')
|
||||
ref = resumed.result.value.ref
|
||||
// A stale ref loses the CAS check.
|
||||
expect((await client.goals.pause({ sessionId: id, ref: { ...ref, revision: 1 } })).result.ok).toBe(false)
|
||||
const completed = await client.goals.complete({ sessionId: id, ref })
|
||||
if (!completed.result.ok) throw new Error('goal complete failed')
|
||||
ref = completed.result.value.ref
|
||||
// complete → complete is an invalid transition.
|
||||
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
|
||||
@@ -154,6 +154,15 @@ export class FakeApiClient implements IApiClient {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Local sparkle icon for the Others tool-row variant (figma 43:31850 leading
|
||||
// glyph is an SF Symbols "sparkles" text glyph — not extractable as vector
|
||||
// data, so this is a hand-authored three-star approximation). Lives here
|
||||
// rather than ui-primitives until the exact glyph is exported and adopted
|
||||
// into the ic_ds_* family.
|
||||
|
||||
export function IconSparkle16({ size = 16, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
|
||||
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
|
||||
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
6
packages/client/ui-goal/README.i18n.yaml
Normal file
6
packages/client/ui-goal/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
|
||||
README.md: 476096a43532a0bf514cd191585872ef17f65c50
|
||||
README.zh.md: 27bd9a2e735cb4895d30eaf3b08dd939a00436fc
|
||||
20
packages/client/ui-goal/README.md
Normal file
20
packages/client/ui-goal/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-ui-goal
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the three mutation verbs (edit / resume / clear over the `goal.*` wire domain); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the `goal.edit`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None beyond the goal mutation's own context event, which appends to the log tail like any other message.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it.
|
||||
- **No keyless snapshot yet** — the assembled-application transcript (boot → projection → GoalBar) is deferred to the post-review cleanup pass recorded on the landing PR.
|
||||
20
packages/client/ui-goal/README.zh.md
Normal file
20
packages/client/ui-goal/README.zh.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-ui-goal
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带三个变更动词(edit / resume / clear,走 `goal.*` 协议域);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
|
||||
|
||||
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接影响:条带动词提交的 `goal.edit`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **只反映持久 phase** —— 投影值有意省略进程本地的 activation(armed/disarmed),条带无法区分 active-but-disarmed 与 armed 状态;resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。
|
||||
- **暂缺 keyless 快照** —— 组装应用级 transcript(boot → 投影 → GoalBar)推迟到落地 PR 记录的评审后收口批次。
|
||||
70
packages/client/ui-goal/package.json
Normal file
70
packages/client/ui-goal/package.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-goal",
|
||||
"description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
120
packages/client/ui-goal/src/client/GoalBar.module.css
Normal file
120
packages/client/ui-goal/src/client/GoalBar.module.css
Normal file
@@ -0,0 +1,120 @@
|
||||
/* GoalBar: the goal strip docked above the composer card. The dock mirrors
|
||||
InputBar's horizontal geometry (32px side padding, 776px centered cap)
|
||||
plus the mock's 12px inset, so the bar's edges land 12px inside the
|
||||
composer card's edges in both the capped and the squeezed regimes. The
|
||||
negative bottom margin eats InputBar's 8px top padding and tucks the
|
||||
bar's square bottom edge 2px under the composer card's top edge (the
|
||||
card, later in DOM order, paints over it). All states share one fixed
|
||||
38px height so switching between them never resizes the strip. */
|
||||
|
||||
.dock {
|
||||
padding: 0 44px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
max-width: 752px;
|
||||
height: 38px;
|
||||
margin: 0 auto -10px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
/* Translucent hover gray doubles as the mock's #F5F6F7 over the white
|
||||
base and lifts the strip off the composer card in dark mode. */
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.objective {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Inline edit form ---- */
|
||||
|
||||
.objectiveInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.objectiveInput:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.objectiveInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* ---- Icon actions ---- */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.iconBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
161
packages/client/ui-goal/src/client/GoalBar.tsx
Normal file
161
packages/client/ui-goal/src/client/GoalBar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* GoalBar: the goal indicator docked above the message composer (input dock
|
||||
* strip). A present goal shows a sparkle, a phase label, the truncated
|
||||
* objective, and icon actions — resume when paused, edit (inline form in the
|
||||
* same strip), and clear. Goal creation lives on the `/goal` command, not
|
||||
* here: loading (undefined), no goal (null), and complete goals render
|
||||
* nothing. Live state arrives as the projected whole snapshot; the verbs are
|
||||
* the injected face.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
import css from './GoalBar.module.css'
|
||||
|
||||
export interface GoalBarProps extends GoalBarActions {
|
||||
/** Current goal snapshot; undefined = capability absent or loading, null = no goal set. */
|
||||
goal: GoalSnapshot | null | undefined
|
||||
}
|
||||
|
||||
/** Strip labels per visible phase; complete goals render nothing. */
|
||||
const PHASE_LABELS = {
|
||||
active: 'Ongoing Goal',
|
||||
paused: 'Paused Goal',
|
||||
blocked: 'Blocked Goal',
|
||||
} as const
|
||||
|
||||
export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
// A new goal identity (cleared/completed/replaced externally) invalidates the local edit
|
||||
// state: without the reset a surviving draft's Enter would write over the NEW goal.
|
||||
const goalId = goal?.id
|
||||
useEffect(() => {
|
||||
setEditing(false)
|
||||
setActionError(null)
|
||||
}, [goalId])
|
||||
|
||||
const handleEdit = useCallback(async () => {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed === '') return
|
||||
setPending(true)
|
||||
setActionError(null)
|
||||
const result = await onEdit(trimmed)
|
||||
setPending(false)
|
||||
if (result.ok) {
|
||||
setEditing(false)
|
||||
} else {
|
||||
setActionError(`${result.error.message} (${result.error.code})`)
|
||||
}
|
||||
}, [draft, onEdit])
|
||||
|
||||
const runAction = useCallback(async (action: () => Promise<GoalActionResult>) => {
|
||||
setPending(true)
|
||||
setActionError(null)
|
||||
const result = await action()
|
||||
setPending(false)
|
||||
if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
|
||||
}, [])
|
||||
|
||||
// Loading, absent, and complete goals have no strip at all.
|
||||
if (goal === undefined || goal === null || goal.phase === 'complete') return null
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={css.dock} data-goal-bar>
|
||||
<div className={css.bar}>
|
||||
<input
|
||||
className={css.objectiveInput}
|
||||
type="text"
|
||||
aria-label="Goal objective"
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleEdit()
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => { void handleEdit() }}
|
||||
disabled={pending || draft.trim() === ''}
|
||||
title="Save goal"
|
||||
aria-label="Save goal"
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => { setEditing(false) }}
|
||||
disabled={pending}
|
||||
title="Cancel edit"
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined
|
||||
return (
|
||||
<div className={css.dock} data-goal-bar>
|
||||
<div className={css.bar} title={title}>
|
||||
<span className={css.sparkle}><IconSparkle16 /></span>
|
||||
<span className={css.label}>{PHASE_LABELS[goal.phase]}</span>
|
||||
<span className={css.objective}>{goal.objective}</span>
|
||||
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
|
||||
<div className={css.actions}>
|
||||
{goal.phase === 'paused' && (
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} title="Resume goal" aria-label="Resume goal">
|
||||
<IconPlayOutline16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
disabled={pending}
|
||||
onClick={() => { setDraft(goal.objective); setEditing(true) }}
|
||||
title="Edit goal"
|
||||
aria-label="Edit goal"
|
||||
>
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onClear) }} title="Clear goal" aria-label="Clear goal">
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Full props of the dock entry: InputZone owner share + session standard kit + injected verbs. */
|
||||
export type GoalDockProps = import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.dock'> & GoalBarActions
|
||||
|
||||
/** Dock adapter: reads the host-computed 'goal' projection (whole value; absent or null renders nothing). */
|
||||
export function GoalDock({ useProjection, onEdit, onResume, onClear }: GoalDockProps) {
|
||||
const projection = useProjection('goal')
|
||||
return (
|
||||
<GoalBar
|
||||
goal={projection === undefined ? undefined : projection === null ? null : projection.goal}
|
||||
onEdit={onEdit}
|
||||
onResume={onResume}
|
||||
onClear={onClear}
|
||||
/>
|
||||
)
|
||||
}
|
||||
82
packages/client/ui-goal/src/client/index.ts
Normal file
82
packages/client/ui-goal/src/client/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Goal surface plugin, browser half: the GoalBar entry in the
|
||||
* conversation.input.dock strip. Projection-mode surface — the live goal
|
||||
* arrives through `useProjection('goal')` (seeded by the history tail page,
|
||||
* updated by session/projection frames), so this plugin owns no store, no
|
||||
* refresh chain, and no event listener. The inject face carries only the
|
||||
* three mutation verbs (edit/resume/clear over the goal.* wire domain);
|
||||
* their CAS ref reads the session's current projected value at call time.
|
||||
* Goal creation stays on the /goal host command.
|
||||
*/
|
||||
import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet).
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
import { GoalDock } from './GoalBar.tsx'
|
||||
|
||||
export { GoalBar, GoalDock } from './GoalBar.tsx'
|
||||
export type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
|
||||
/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs. */
|
||||
export const inject = ['slots', 'sessions', 'connection']
|
||||
|
||||
/** Map one settled RPC result onto the strip's inline-render shape. */
|
||||
function settle<T>(result: RpcResult<T>): GoalActionResult {
|
||||
if (result.ok) return { ok: true }
|
||||
return { ok: false, error: { code: result.error.code, message: result.error.message } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: the GoalBar dock entry with its mutation verbs.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { goals } = (ctx.get('connection') as ConnectionHandle).api
|
||||
|
||||
// Conditional mount: 'conversation.input.dock' is declared by the
|
||||
// conversation entry; the conversation service being up is the
|
||||
// registration-safe signal (the TodoDock/QueueDock seam).
|
||||
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
|
||||
const sessions = scope.sessions
|
||||
|
||||
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
|
||||
const refOf = (sessionId: SessionId): GoalRef | undefined => {
|
||||
const face = sessions.binding(sessionId)?.session.projections.faceOf('goal')
|
||||
const projection = face?.getSnapshot() as GoalProjection | null | undefined
|
||||
if (projection == null) return undefined
|
||||
return { id: projection.goal.id, revision: projection.goal.revision }
|
||||
}
|
||||
|
||||
const noCurrentGoal: GoalActionResult = {
|
||||
ok: false,
|
||||
error: { code: 'no-current-goal', message: 'no current goal to mutate' },
|
||||
}
|
||||
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 1,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.edit({ sessionId, ref, objective })).result)
|
||||
},
|
||||
onResume: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.resume({ sessionId, ref })).result)
|
||||
},
|
||||
onClear: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.clear({ sessionId, ref })).result)
|
||||
},
|
||||
}),
|
||||
}, GoalDock), 'ui-goal: GoalBar dock registration')
|
||||
})
|
||||
}
|
||||
26
packages/client/ui-goal/src/client/slots.ts
Normal file
26
packages/client/ui-goal/src/client/slots.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* GoalBar's injected face. The target 'conversation.input.dock' slot is
|
||||
* declared (children table) and typed by ui-conversation; this package only
|
||||
* contributes the entry, so no SlotMap merge lives here. The live goal value
|
||||
* is NOT part of this face — it arrives through `useProjection('goal')`
|
||||
* (the framework standard kit); inject carries only the mutation verbs
|
||||
* (callbacks from inject, live state from useProjection).
|
||||
*/
|
||||
|
||||
/** Settled outcome of one goal mutation, rendered inline by the strip. */
|
||||
export type GoalActionResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */
|
||||
export interface GoalBarActions {
|
||||
/**
|
||||
* Replace the current goal's objective (CAS on the projected ref).
|
||||
* @param objective - replacement objective text.
|
||||
*/
|
||||
onEdit: (objective: string) => Promise<GoalActionResult>
|
||||
/** Resume a paused goal. */
|
||||
onResume: () => Promise<GoalActionResult>
|
||||
/** Clear the current goal (tombstone). */
|
||||
onClear: () => Promise<GoalActionResult>
|
||||
}
|
||||
6
packages/client/ui-goal/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-goal/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-goal/src/index.ts
Normal file
9
packages/client/ui-goal/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Goal surface plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader; the browser half
|
||||
* ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
32
packages/client/ui-goal/src/invariant.ts
Normal file
32
packages/client/ui-goal/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-goal`.
|
||||
* @module @deepseek-ai/dsh-client-ui-goal/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-goal'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-goal-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single GoalBar dock registration whose disposal is
|
||||
* proven by the HMR-safety spec — the plugin owns no store (state arrives on
|
||||
* the goal projection), emits no cordis events, and holds no cross-plugin
|
||||
* mutable state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
170
packages/client/ui-goal/tests/browser-plugin.spec.tsx
Normal file
170
packages/client/ui-goal/tests/browser-plugin.spec.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ui-goal browser half on a real cordis Context with fake slots/connection/
|
||||
* sessions faces: the plugin registers the GoalBar dock entry at
|
||||
* conversation.input.dock, the inject face's three verbs read the CAS ref
|
||||
* from the session's CURRENT projected value at call time (no fence — the
|
||||
* RPC's compare-and-set is the guard), a missing projection short-circuits
|
||||
* to the no-current-goal error without touching the wire, and RPC errors
|
||||
* map onto the inline-render result shape. Registration disposal rides the
|
||||
* plugin fiber (HMR safety). The node half and the invariant companion are
|
||||
* exercised over the same Context.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import type { GoalBarActions } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { GoalDock } from '../src/client/GoalBar.tsx'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
function makeProjection(revision = 3): GoalProjection {
|
||||
return {
|
||||
goal: {
|
||||
id: 'g-1' as GoalProjection['goal']['id'],
|
||||
revision,
|
||||
objective: 'Ship it',
|
||||
phase: 'active',
|
||||
maxGoalRounds: 8,
|
||||
},
|
||||
roundsStarted: 1,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */
|
||||
function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; payload: unknown }[] = []
|
||||
function answer<T>(method: string, value: T) {
|
||||
return (payload: unknown) => {
|
||||
calls.push({ method, payload })
|
||||
return Promise.resolve({
|
||||
result: options.failWith === undefined
|
||||
? { ok: true as const, value }
|
||||
: { ok: false as const, error: { ...options.failWith, details: {} } },
|
||||
})
|
||||
}
|
||||
}
|
||||
const ref = { id: 'g-1', revision: 3 }
|
||||
ctx.provide('connection', { api: { goals: {
|
||||
edit: answer('goal.edit', { ref }),
|
||||
resume: answer('goal.resume', { ref }),
|
||||
clear: answer('goal.clear', { cleared: true as const }),
|
||||
} } })
|
||||
const entries = new Map<string, { id?: string; order?: number; inject?: (sessionId: SessionId) => GoalBarActions }>()
|
||||
ctx.provide('slots', {
|
||||
register(reg: { name: string; id?: string; order?: number; inject?: (sessionId: SessionId) => GoalBarActions }) {
|
||||
entries.set(reg.name, reg)
|
||||
return () => { entries.delete(reg.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessions', {
|
||||
binding: (id: SessionId) => ({
|
||||
sessionId: id,
|
||||
session: { projections: { faceOf: (key: string) => ({
|
||||
getSnapshot: () => (key === 'goal' ? options.projection : undefined),
|
||||
subscribe: () => () => {},
|
||||
}) } },
|
||||
ctx,
|
||||
}),
|
||||
})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return {
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
entry: () => entries.get('conversation.input.dock'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 1 })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('verbs read the CAS ref from the current projected value at call time', async () => {
|
||||
const b = bench({ projection: makeProjection(5) })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
|
||||
expect(await verbs.onResume()).toEqual({ ok: true })
|
||||
expect(await verbs.onClear()).toEqual({ ok: true })
|
||||
expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.resume', 'goal.clear'])
|
||||
const ref = { id: 'g-1', revision: 5 }
|
||||
expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' })
|
||||
expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref })
|
||||
expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref })
|
||||
})
|
||||
|
||||
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
|
||||
for (const projection of [null, undefined]) {
|
||||
const b = bench({ projection })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
for (const result of [await verbs.onEdit('x'), await verbs.onResume(), await verbs.onClear()]) {
|
||||
expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } })
|
||||
}
|
||||
expect(b.calls).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps a settled RPC error onto the inline-render shape', async () => {
|
||||
const b = bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
|
||||
})
|
||||
|
||||
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toBeDefined()
|
||||
await b.fiber.dispose()
|
||||
expect(b.entry()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GoalDock adapter', () => {
|
||||
it('renders the projected goal snapshot and nothing for absent/null', () => {
|
||||
const projection = makeProjection()
|
||||
const useProjection = vi.fn(() => projection)
|
||||
const actions: GoalBarActions = {
|
||||
onEdit: () => Promise.resolve({ ok: true }),
|
||||
onResume: () => Promise.resolve({ ok: true }),
|
||||
onClear: () => Promise.resolve({ ok: true }),
|
||||
}
|
||||
const dockProps = (up: () => GoalProjection | null | undefined) =>
|
||||
({ useProjection: up, ...actions }) as unknown as Parameters<typeof GoalDock>[0]
|
||||
const shown = render(<GoalDock {...dockProps(useProjection)} />)
|
||||
expect(shown.getByText('Ship it')).toBeTruthy()
|
||||
cleanup()
|
||||
|
||||
const empty = render(<GoalDock {...dockProps(() => null)} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const absent = render(<GoalDock {...dockProps(() => undefined)} />)
|
||||
expect(absent.container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-goal node half', () => {
|
||||
// The invariant companion is mounted by the vitest-wide invariant host on
|
||||
// every Context this suite creates; its registration is covered there.
|
||||
it('the node apply is an inert loader seat', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
170
packages/client/ui-goal/tests/goalbar.spec.tsx
Normal file
170
packages/client/ui-goal/tests/goalbar.spec.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
// GoalBar behavior: the docked strip above the composer — phase labels,
|
||||
// inline edit form, and resume/clear icon actions — driven purely through
|
||||
// props, no wire. Loading, absent, and complete goals render nothing.
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
|
||||
import { GoalBar } from '../src/client/GoalBar.tsx'
|
||||
import type { GoalBarActions } from '../src/client/slots.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
|
||||
return {
|
||||
id: 'g1' as GoalSnapshot['id'],
|
||||
revision: 1,
|
||||
objective: 'Ship the redesign',
|
||||
phase: 'active',
|
||||
maxGoalRounds: 4,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function makeActions() {
|
||||
return {
|
||||
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })),
|
||||
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })),
|
||||
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })),
|
||||
} satisfies GoalBarActions
|
||||
}
|
||||
|
||||
describe('GoalBar', () => {
|
||||
it('renders nothing while loading, absent, or when the goal is complete', () => {
|
||||
const actions = makeActions()
|
||||
const loading = render(<GoalBar goal={undefined} {...actions} />)
|
||||
expect(loading.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const absent = render(<GoalBar goal={null} {...actions} />)
|
||||
expect(absent.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const complete = render(<GoalBar goal={makeGoal({ phase: 'complete' })} {...actions} />)
|
||||
expect(complete.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('active goal: sparkle, "Ongoing Goal", truncated objective, edit and clear actions', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('Ship the redesign')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
|
||||
expect(actions.onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
const box = screen.getByRole('textbox', { name: 'Goal objective' })
|
||||
expect(box).toHaveProperty('value', 'Ship the redesign')
|
||||
|
||||
fireEvent.change(box, { target: { value: ' ' } })
|
||||
expect(screen.getByRole('button', { name: 'Save goal' })).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(box, { target: { value: 'Ship v2' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(actions.onEdit).toHaveBeenCalledWith('Ship v2')
|
||||
await waitFor(() => { expect(screen.getByText('Ongoing Goal')).toBeTruthy() })
|
||||
})
|
||||
|
||||
it('Esc cancels the edit without calling onEdit', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Goal objective' }), { key: 'Escape' })
|
||||
expect(actions.onEdit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the cancel button exits the form and drops the draft (re-edit starts from the objective)', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'abandoned draft' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel edit' }))
|
||||
expect(actions.onEdit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Goal objective' })).toHaveProperty('value', 'Ship the redesign')
|
||||
})
|
||||
|
||||
it('Enter with a blank draft neither saves nor closes the form', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
const box = screen.getByRole('textbox', { name: 'Goal objective' })
|
||||
fireEvent.change(box, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(actions.onEdit).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('textbox', { name: 'Goal objective' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('paused goal: "Paused Goal" with a resume action before edit', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
|
||||
expect(screen.getByText('Paused Goal')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
|
||||
expect(actions.onResume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a new goal identity drops the edit form (no stale draft over the new goal)', () => {
|
||||
const actions = makeActions()
|
||||
const { rerender } = render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'stale draft' } })
|
||||
|
||||
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'New goal' })} {...actions} />)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('New goal')).toBeTruthy()
|
||||
|
||||
rerender(<GoalBar goal={null} {...actions} />)
|
||||
expect(screen.queryByText('Ongoing Goal')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocked goal: "Blocked Goal" with the block reason as the strip tooltip', () => {
|
||||
const actions = makeActions()
|
||||
const goal = makeGoal({ phase: 'blocked', blockedReason: { code: 'stalled', message: 'No progress in 3 rounds' } })
|
||||
render(<GoalBar goal={goal} {...actions} />)
|
||||
expect(screen.getByText('Blocked Goal')).toBeTruthy()
|
||||
expect(screen.getByText('Blocked Goal').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
|
||||
})
|
||||
|
||||
it('blocked goal without a reason carries no tooltip', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal({ phase: 'blocked' })} {...actions} />)
|
||||
expect(screen.getByText('Blocked Goal')).toBeTruthy()
|
||||
expect(screen.getByText('Blocked Goal').closest('[title]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the edit draft open and reports a failed save', async () => {
|
||||
const actions = makeActions()
|
||||
actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } })
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
const box = screen.getByRole('textbox', { name: 'Goal objective' })
|
||||
fireEvent.change(box, { target: { value: 'retry this draft' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save goal' }))
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('stale revision (agent-busy)')
|
||||
expect(screen.getByRole('textbox', { name: 'Goal objective' })).toHaveProperty('value', 'retry this draft')
|
||||
})
|
||||
|
||||
it('reports resume and clear failures without hiding the goal', async () => {
|
||||
const actions = makeActions()
|
||||
actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } })
|
||||
const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)')
|
||||
|
||||
actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } })
|
||||
rerender(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')
|
||||
expect(screen.getByText('Ship the redesign')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
36
packages/client/ui-goal/tsconfig.json
Normal file
36
packages/client/ui-goal/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-goal/tsdown.config.ts
Normal file
3
packages/client/ui-goal/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -680,3 +680,14 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** sparkle_16 (Others tool-row / goal strip leading glyph; hand-authored three-star
|
||||
* approximation — the figma 43:31850 glyph is an SF Symbols "sparkles" text glyph,
|
||||
* not extractable as vector data) */
|
||||
export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
|
||||
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
|
||||
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(56)
|
||||
it('exports the full P-I set (43 deepsuite + 13 figma extracts + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(57)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
Reference in New Issue
Block a user