feat(gui): goal wire domain, client goal state, and docked goal bar

- apiproxy goals RPC domain (get/create/edit/pause/resume/complete/clear)
  with CAS refs, zod schemas, and fetch client/handler wiring
- client runtime session goal state: live goal/change meta triggers a
  coalesced refetch; mutations fold transport errors into RpcResult
- web GoalBar: docked strip above the composer (sparkle, phase label,
  truncated objective, inline edit, clear; resume when paused); creation
  stays on the /goal command
- GoalBarActions in the ui-conversation contract layer; IconSparkle16
  moves to ui-primitives icons
This commit is contained in:
_Kerman
2026-07-22 20:51:44 +08:00
parent 0681ac47de
commit 923535fa7a
40 changed files with 1358 additions and 28 deletions

View File

@@ -8,6 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -423,6 +423,15 @@ export function createFixtureApi(): ApiProxy {
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
},
goals: {
get: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
create: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
edit: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
pause: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
resume: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
complete: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
clear: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
},
events: {
async *mux(_request, signal) {
const conn = new FxInbox<MuxFrame>()
@@ -514,6 +523,13 @@ 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 'goal.get': return this.api.goals.get(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)
}
}

View File

@@ -20,6 +20,7 @@ export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason,
} from './api.ts'
export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts'

View File

@@ -71,6 +71,16 @@ export class FakeApiClient implements IApiClient {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
}
readonly goals: IApiClient['goals'] = {
get: payload => this.record('goal.get', payload, Promise.resolve(ok({ goal: null }))),
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
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

View File

@@ -28,6 +28,7 @@ export type {
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
export type { GoalView, GoalRef, GoalPhase, GoalBlockReason } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// ui-slots/web-react stay generic and dependency-inverted; the client-tree

View File

@@ -4,7 +4,7 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { GoalView, RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
@@ -162,4 +162,7 @@ export interface ConversationSnapshot {
loadingOlder: boolean
promptError: PromptError | null
lastAgentError: string | null
/** Current goal projection (fetched on open / refreshed when a live context/message carries
* goal/change meta). undefined = not yet loaded. */
goal: GoalView | null | undefined
}

View File

@@ -5,7 +5,7 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import type { GoalView, HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -61,6 +61,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Current goal projection; undefined = not yet fetched, null = no goal set. */
private goal: GoalView | null | undefined = undefined
/** Coalesced goal refetch (the open() idiom): live goal-change events share one in-flight get. */
private goalFetch: Promise<void> | null = null
/** Bumped on every local goal write; a get result older than the latest write is stale and
* dropped (a mutation response that landed mid-fetch is always newer than the get's read). */
private goalWriteRev = 0
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
@@ -120,6 +127,180 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/** Fetch the current goal, coalesced: concurrent triggers share the in-flight get (identity-guarded
* like openPromise — a superseded fetch must not null out the one that replaced it). */
private fetchGoal(): Promise<void> {
if (this.goalFetch !== null) return this.goalFetch
const promise = this.doFetchGoal().finally(() => {
if (this.goalFetch === promise) this.goalFetch = null
})
this.goalFetch = promise
return promise
}
/** The get behind fetchGoal: folds transport failures (fail-soft like loadOlder, logged) and
* drops the result when a mutation response landed mid-flight (write revision moved on). */
private async doFetchGoal(): Promise<void> {
const writeRev = this.goalWriteRev
try {
const { result } = await this.api.goals.get({ sessionId: this.sessionId })
if (!result.ok) return
if (writeRev !== this.goalWriteRev) return
this.goal = result.value.goal
this.notifier.markDirty()
} catch (error) {
console.error('[web-runtime] goal fetch failed:', error)
}
}
/**
* Create a goal for this session.
* @param objective - the goal's objective text.
* @param maxGoalRounds - optional cap on admitted goal rounds (host default when absent).
* @returns the created goal view; transport failures fold into a failed result, never a rejection.
*/
async createGoal(objective: string, maxGoalRounds?: number): Promise<RpcResult<{ goal: GoalView }>> {
let result: RpcResult<{ goal: GoalView }>
try {
result = (await this.api.goals.create({
sessionId: this.sessionId, objective, ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
})).result
} catch (error) {
result = transportError(error)
}
if (result.ok) {
this.goal = result.value.goal
this.goalWriteRev++
this.notifier.markDirty()
}
return result
}
/**
* Edit this session's goal objective or round cap (CAS with the locally held revision).
* @param objective - replacement objective text; absent leaves it unchanged.
* @param maxGoalRounds - replacement round cap; absent leaves it unchanged.
* @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result.
*/
async editGoal(objective?: string, maxGoalRounds?: number): Promise<RpcResult<{ goal: GoalView }>> {
if (this.goal === null || this.goal === undefined) {
return { ok: false, error: { code: 'internal', message: 'No goal to edit', details: {} } }
}
let result: RpcResult<{ goal: GoalView }>
try {
result = (await this.api.goals.edit({
sessionId: this.sessionId,
ref: { id: this.goal.id, revision: this.goal.revision },
...(objective !== undefined ? { objective } : {}),
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
})).result
} catch (error) {
result = transportError(error)
}
if (result.ok) {
this.goal = result.value.goal
this.goalWriteRev++
this.notifier.markDirty()
}
return result
}
/**
* Pause the current goal (CAS with the locally held revision).
* @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result.
*/
async pauseGoal(): Promise<RpcResult<{ goal: GoalView }>> {
if (this.goal === null || this.goal === undefined) {
return { ok: false, error: { code: 'internal', message: 'No goal to pause', details: {} } }
}
let result: RpcResult<{ goal: GoalView }>
try {
result = (await this.api.goals.pause({
sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision },
})).result
} catch (error) {
result = transportError(error)
}
if (result.ok) {
this.goal = result.value.goal
this.goalWriteRev++
this.notifier.markDirty()
}
return result
}
/**
* Resume a paused/blocked goal (CAS with the locally held revision).
* @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result.
*/
async resumeGoal(): Promise<RpcResult<{ goal: GoalView }>> {
if (this.goal === null || this.goal === undefined) {
return { ok: false, error: { code: 'internal', message: 'No goal to resume', details: {} } }
}
let result: RpcResult<{ goal: GoalView }>
try {
result = (await this.api.goals.resume({
sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision },
})).result
} catch (error) {
result = transportError(error)
}
if (result.ok) {
this.goal = result.value.goal
this.goalWriteRev++
this.notifier.markDirty()
}
return result
}
/**
* Complete the current goal (CAS with the locally held revision).
* @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result.
*/
async completeGoal(): Promise<RpcResult<{ goal: GoalView }>> {
if (this.goal === null || this.goal === undefined) {
return { ok: false, error: { code: 'internal', message: 'No goal to complete', details: {} } }
}
let result: RpcResult<{ goal: GoalView }>
try {
result = (await this.api.goals.complete({
sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision },
})).result
} catch (error) {
result = transportError(error)
}
if (result.ok) {
this.goal = result.value.goal
this.goalWriteRev++
this.notifier.markDirty()
}
return result
}
/**
* Clear the current goal (tombstone; CAS with the locally held revision).
* @returns a cleared marker; fails without a current goal, and transport failures fold into a failed result.
*/
async clearGoal(): Promise<RpcResult<{ cleared: true }>> {
if (this.goal === null || this.goal === undefined) {
return { ok: false, error: { code: 'internal', message: 'No goal to clear', details: {} } }
}
let result: RpcResult<{ cleared: true }>
try {
result = (await this.api.goals.clear({
sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision },
})).result
} catch (error) {
result = transportError(error)
}
if (result.ok) {
this.goal = null
this.goalWriteRev++
this.notifier.markDirty()
}
return result
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
@@ -317,6 +498,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
}
this.openState = 'open'
// Fetch the current goal eagerly after the window lands.
void this.fetchGoal()
} catch (error) {
if (generation !== this.openGeneration) return
this.openState = 'error'
@@ -353,6 +536,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.views.push(view)
this.foldAdapter.append(event, view)
this.applyEventSideEffects(event, view)
// Goal mutations surface as goal/change meta on a context/message (clear tombstones carry no
// goal key, so match the meta kind). LIVE events only: window rebuilds replay the same meta
// and would storm goal.get on open/resync/loadOlder; the refetch coalesces via goalFetch.
if (event.type === 'context/message'
&& (event.data.meta as { kind?: unknown } | undefined)?.kind === 'goal/change') {
void this.fetchGoal()
}
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
@@ -522,6 +712,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
loadingOlder: this.loadingOlder,
promptError: this.promptError,
lastAgentError: this.lastAgentError,
goal: this.goal,
}
}
}

View File

@@ -74,6 +74,16 @@ export class FakeApiClient implements IApiClient {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
}
readonly goals: IApiClient['goals'] = {
get: payload => this.record('goal.get', payload, Promise.resolve(ok({ goal: null }))),
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
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

View File

@@ -7,6 +7,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { GoalView } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
@@ -623,3 +624,233 @@ describe('reference stability (the memo contract)', () => {
expect(resolved.pending).toBe(after.pending)
})
})
describe('goal session methods', () => {
const GID = 'g-1' as never
function makeGoal(overrides: Partial<GoalView> = {}): GoalView {
return {
id: GID, revision: 1, objective: 'test-goal', phase: 'active',
maxGoalRounds: 256, roundsStarted: 0, createdAt: 100, updatedAt: 100,
activation: 'armed',
...overrides,
}
}
it('createGoal calls the API and updates snapshot.goal', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
const r = await session.createGoal('test-goal')
expect(r).toEqual({ ok: true, value: { goal } })
expect(session.getSnapshot().goal).toEqual(goal)
})
it('editGoal sends the current ref and updates snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const edited = makeGoal({ revision: 2, objective: 'edited' })
api.goals.edit = () => Promise.resolve(ok({ goal: edited }))
const r = await session.editGoal('edited')
expect(r).toEqual({ ok: true, value: { goal: edited } })
expect(session.getSnapshot().goal).toEqual(edited)
})
it('editGoal returns an error when no goal exists', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const r = await session.editGoal('nothing')
expect(r.ok).toBe(false)
expect((r as { error: { code: string } }).error.code).toBe('internal')
})
it('pauseGoal pauses and updates snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const paused = makeGoal({ revision: 2, phase: 'paused', activation: 'disarmed' })
api.goals.pause = () => Promise.resolve(ok({ goal: paused }))
const r = await session.pauseGoal()
expect(r).toEqual({ ok: true, value: { goal: paused } })
expect(session.getSnapshot().goal).toEqual(paused)
})
it('resumeGoal resumes a paused goal', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal({ phase: 'paused', activation: 'disarmed' })
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const resumed = makeGoal({ revision: 2, phase: 'active', activation: 'armed' })
api.goals.resume = () => Promise.resolve(ok({ goal: resumed }))
const r = await session.resumeGoal()
expect(r).toEqual({ ok: true, value: { goal: resumed } })
expect(session.getSnapshot().goal).toEqual(resumed)
})
it('completeGoal marks the goal complete', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' })
api.goals.complete = () => Promise.resolve(ok({ goal: completed }))
const r = await session.completeGoal()
expect(r).toEqual({ ok: true, value: { goal: completed } })
expect(session.getSnapshot().goal).toEqual(completed)
})
it('clearGoal removes the goal from snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
api.goals.clear = () => Promise.resolve(ok({ cleared: true as const }))
const r = await session.clearGoal()
expect(r).toEqual({ ok: true, value: { cleared: true } })
expect(session.getSnapshot().goal).toBeNull()
})
it('error responses from the API do not mutate local state', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const before = session.getSnapshot().goal
api.goals.pause = () => Promise.resolve(err({ code: 'agent-busy', message: 'stale revision', details: { reason: 'stale revision' } }))
const r = await session.pauseGoal()
expect(r.ok).toBe(false)
expect(session.getSnapshot().goal).toBe(before)
})
it('fetchGoal runs on session open and populates goal', async () => {
const goal = makeGoal()
const { api, session } = makeSession()
api.onHistory = () => histResponse([])
api.goals.get = () => Promise.resolve(ok({ goal }))
await session.open()
expect(session.getSnapshot().goal).toEqual(goal)
})
const goalChangeEvent = (seq: number, operation: string): SessionEvent =>
at(seq, {
type: 'context/message', surfaceOp: 'append',
data: {
content: [{ type: 'text', text: `goal ${operation}` }],
source: { kind: 'goal', goalId: 'g-1', revision: 1, round: 0 },
meta: {
kind: 'goal/change', version: 1, operation,
goal: { id: 'g-1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 256 },
roundsStarted: 0, createdAt: 100, updatedAt: 100,
},
},
})
// Clear tombstones carry no goal key — the trigger matches the meta kind, not a goal field.
const goalClearEvent = (seq: number): SessionEvent =>
at(seq, {
type: 'context/message', surfaceOp: 'append',
data: {
content: [{ type: 'text', text: 'goal cleared' }],
source: { kind: 'goal', goalId: 'g-1', revision: 2, round: 0 },
meta: { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'g-1', revision: 2 }, clearedAt: 100 },
},
})
it('live goal-change meta triggers one coalesced refetch; window replays never refetch', async () => {
const { api, session } = makeSession()
// The history window replays goal-change meta (a snapshot change AND a clear tombstone).
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), goalChangeEvent(6, 'create'), goalClearEvent(7)])
await session.open()
await Promise.resolve()
expect(api.callsOf('goal.get')).toHaveLength(1) // the eager open fetch only — no replay storm
// A plain live context message is not a goal change.
let refetches = 0
const gate = deferred<Awaited<ReturnType<FakeApiClient['goals']['get']>>>()
api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally
session.handleMuxEnvelope('r0' as never, {
type: 'session/event', sessionId: SID,
event: at(8, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: 'plain' }], source: { kind: 'system' } } }),
})
expect(refetches).toBe(0)
// Two live goal events (change + clear tombstone) coalesce into a single refetch.
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(9, 'edit') })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: goalClearEvent(10) })
expect(refetches).toBe(1)
const goal = makeGoal({ revision: 3 })
gate.resolve(ok({ goal }))
await vi.waitFor(() => { expect(session.getSnapshot().goal).toEqual(goal) })
})
it('drops a goal.get result older than a mutation response that landed mid-flight', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
let refetches = 0
const gate = deferred<Awaited<ReturnType<FakeApiClient['goals']['get']>>>()
api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'edit') })
expect(refetches).toBe(1) // the live refetch is parked on the gate
const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' })
api.goals.complete = () => Promise.resolve(ok({ goal: completed }))
await session.completeGoal() // newer write lands while the get is in flight
gate.resolve(ok({ goal: makeGoal({ objective: 'stale-read' }) }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(session.getSnapshot().goal).toEqual(completed) // the stale read never overwrote it
})
it('folds transport rejections from goal mutations into { ok: false } without rejecting', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
api.goals.create = () => Promise.reject(new Error('goal wire down'))
const created = await session.createGoal('test-goal')
expect(created).toMatchObject({ ok: false, error: { code: 'internal', message: 'goal wire down' } })
expect(session.getSnapshot().goal).toBeNull()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
api.goals.pause = () => Promise.reject(new Error('pause wire down'))
const paused = await session.pauseGoal()
expect(paused).toMatchObject({ ok: false, error: { code: 'internal', message: 'pause wire down' } })
expect(session.getSnapshot().goal).toEqual(goal) // local state untouched
})
it('a goal.get transport rejection on a live refetch is logged and swallowed', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
api.goals.get = () => Promise.reject(new Error('get wire down'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'create') })
await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() })
expect(session.getSnapshot().goal).toBeNull() // fail-soft: state untouched
} finally {
errorSpy.mockRestore()
}
})
})

View File

@@ -15,7 +15,7 @@ import type {
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected, GoalBarActions } from './contract/slots.ts'
import { ConversationService } from './service.ts'
import { ToolViewRegistry } from './toolviews/registry.ts'
import { childSessionScope, registerChat } from './chat/register.ts'
@@ -153,6 +153,11 @@ export function apply(ctx: Context): void {
}
return createElement(Fragment, null, ...children)
},
goalActions: {
onEdit: (objective) => { void session.editGoal(objective) },
onResume: () => { void session.resumeGoal() },
onClear: () => { void session.clearGoal() },
} satisfies GoalBarActions,
}
return injected
}

View File

@@ -4,12 +4,11 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconSparkle16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolViewProps } from '../contract/toolview.ts'
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
/** Variant leading icons (figma table). */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {

View File

@@ -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>
)
}

View File

@@ -13,6 +13,16 @@ import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-w
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
/** Goal strip callbacks (the docked GoalBar's verb set). State is read from useSession. */
export interface GoalBarActions {
/** Replace the current goal's objective. */
onEdit(objective: string): void
/** Resume a paused goal. */
onResume(): void
/** Clear the current goal (tombstone). */
onClear(): void
}
/** Injected share of the conversation slot (assembled by apply's inject factory). */
export interface ConversationInjected {
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
@@ -38,6 +48,8 @@ export interface ConversationInjected {
}
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
renderView: (entry: ViewEntry) => ReactNode
/** Goal callbacks (undefined = goal feature not available). State is read from useSession. */
goalActions?: GoalBarActions
}
/** Full conversation-slot component props: owner share & standard share & injected share. */

View File

@@ -22,7 +22,7 @@ export type {
} from './contract/toolview.ts'
export type {
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps,
EmptyStateInjected, EmptyStateSlotProps, GoalBarActions,
} from './contract/slots.ts'
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'

View File

@@ -10,6 +10,8 @@ import clsx from 'clsx'
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import { GoalBar } from './GoalBar.tsx'
import type { GoalBarProps } from './GoalBar.tsx'
import css from './ConversationRoot.module.css'
/**
@@ -20,7 +22,7 @@ import css from './ConversationRoot.module.css'
export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, goalActions,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
@@ -33,6 +35,7 @@ export function ConversationRoot({
const removed = useSession(s => (s as { removed: boolean }).removed)
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
const goal = useSession(s => (s as { goal: unknown }).goal)
const error: InputBarError | null = promptError === null
? null
@@ -87,6 +90,11 @@ export function ConversationRoot({
{active !== undefined && renderView(active)}
</div>
{/* GoalBar docks directly above the composer (its CSS mirrors the
composer's horizontal geometry and tucks under the card's top edge). */}
{goalActions !== undefined && (
<GoalBar goal={goal as GoalBarProps['goal']} {...goalActions} />
)}
<InputBar
draft={draft}
running={running}

View File

@@ -0,0 +1,109 @@
/* 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;
}
/* ---- 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;
}

View File

@@ -0,0 +1,122 @@
/**
* GoalBar: the goal indicator docked directly above the message composer
* (rounded-top strip tucked under the composer card's top edge). 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.
*/
import { useCallback, useEffect, useState } from 'react'
import type { GoalView } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GoalBarActions } from '../contract/slots.ts'
import css from './GoalBar.module.css'
export interface GoalBarProps extends GoalBarActions {
/** Current goal state; undefined = still loading, null = no goal set. */
goal: GoalView | 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('')
// 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)
}, [goalId])
const handleEdit = useCallback(() => {
const trimmed = draft.trim()
if (trimmed === '') return
onEdit(trimmed)
setEditing(false)
}, [draft, onEdit])
// 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}>
<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') handleEdit()
if (e.key === 'Escape') setEditing(false)
}}
autoFocus
/>
<div className={css.actions}>
<button
type="button"
className={css.iconBtn}
onClick={handleEdit}
disabled={draft.trim() === ''}
title="Save goal"
aria-label="Save goal"
>
<IconCheckOutline16 />
</button>
<button
type="button"
className={css.iconBtn}
onClick={() => setEditing(false)}
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}>
<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>
<div className={css.actions}>
{goal.phase === 'paused' && (
<button type="button" className={css.iconBtn} onClick={onResume} title="Resume goal" aria-label="Resume goal">
<IconPlayOutline16 />
</button>
)}
<button
type="button"
className={css.iconBtn}
onClick={() => { setDraft(goal.objective); setEditing(true) }}
title="Edit goal"
aria-label="Edit goal"
>
<IconEditOutline16 />
</button>
<button type="button" className={css.iconBtn} onClick={onClear} title="Clear goal" aria-label="Clear goal">
<IconTrashOutline16 />
</button>
</div>
</div>
</div>
)
}

View File

@@ -39,7 +39,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined,
} as ConversationSnapshot
}

View File

@@ -31,6 +31,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
goal: undefined,
}
}

View File

@@ -25,6 +25,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
goal: undefined,
}
}

View File

@@ -27,7 +27,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined,
} as ConversationSnapshot
}

View File

@@ -0,0 +1,116 @@
// @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 } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GoalView } from '@deepseek-ai/dsh-client-runtime/client'
import { GoalBar } from '../src/client/skeleton/GoalBar.tsx'
import type { GoalBarActions } from '../src/client/contract/slots.ts'
afterEach(cleanup)
function makeGoal(over: Partial<GoalView> = {}): GoalView {
return {
id: 'g1' as GoalView['id'],
revision: 1,
objective: 'Ship the redesign',
phase: 'active',
maxGoalRounds: 4,
roundsStarted: 1,
createdAt: 1,
updatedAt: 2,
activation: 'armed',
...over,
}
}
function makeActions(): { [K in keyof GoalBarActions]: ReturnType<typeof vi.fn<GoalBarActions[K]>> } {
return {
onEdit: vi.fn<GoalBarActions['onEdit']>(),
onResume: vi.fn<GoalBarActions['onResume']>(),
onClear: vi.fn<GoalBarActions['onClear']>(),
}
}
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', () => {
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 as HTMLInputElement).value).toBe('Ship the redesign')
fireEvent.change(box, { target: { value: ' ' } })
expect((screen.getByRole('button', { name: 'Save goal' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(box, { target: { value: 'Ship v2' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(actions.onEdit).toHaveBeenCalledWith('Ship v2')
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('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 GoalView['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')
})
})

View File

@@ -20,7 +20,7 @@ function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined,
} as ConversationSnapshot
}

View File

@@ -133,6 +133,92 @@ describe('ConversationRoot', () => {
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('queue')
})
it('renders GoalBar when goalActions and an active goal are provided', () => {
const goal = {
id: 'g1' as never,
revision: 1,
objective: 'test-objective',
phase: 'active' as const,
maxGoalRounds: 256,
roundsStarted: 0,
createdAt: 100,
updatedAt: 100,
activation: 'armed' as const,
}
const store = createSnapshotStore<FakeSnapshot & { goal: typeof goal }>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, goal,
})
const useSession = bindSnapshotSelector(store) as unknown as UseSession
const activeStore = createSnapshotStore<string | undefined>('chat')
const views = [view('chat', 'Chat')]
render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useAncestry={() => []}
views={{
list: () => views,
subscribe: () => () => {},
version: () => 1,
}}
useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined}
composer={{
useDraft: () => '',
setDraft: () => {},
send: () => {},
stop: () => {},
}}
actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }}
renderView={() => null}
goalActions={{
onEdit: vi.fn(),
onResume: vi.fn(),
onClear: vi.fn(),
}}
/>)
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
expect(screen.getByText('test-objective')).toBeTruthy()
})
it('hides GoalBar when goalActions is undefined (even with an active goal in the store)', () => {
const goal = {
id: 'g1' as never,
revision: 1,
objective: 'test-objective',
phase: 'active' as const,
maxGoalRounds: 256,
roundsStarted: 0,
createdAt: 100,
updatedAt: 100,
activation: 'armed' as const,
}
const store = createSnapshotStore<FakeSnapshot & { goal: typeof goal }>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, goal,
})
const useSession = bindSnapshotSelector(store) as unknown as UseSession
render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useAncestry={() => []}
views={{
list: () => [],
subscribe: () => () => {},
version: () => 1,
}}
useActiveView={() => 'chat' as ViewId}
composer={{
useDraft: () => '',
setDraft: () => {},
send: () => {},
stop: () => {},
}}
actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }}
renderView={() => null}
/>)
expect(screen.queryByText('Ongoing Goal')).toBeNull()
})
})
describe('DetailsPanel', () => {

View File

@@ -573,3 +573,14 @@ export const IconTreeCorner8x10 = ({ size = 10, className }: IconProps) => (
<path d="M0 0L-0.5 0L-0.5 7L0 7L0.5 7L0.5 0L0 0ZM3 10L3 10.5L8 10.5L8 10L8 9.5L3 9.5L3 10ZM0 7L-0.5 7C-0.5 8.933 1.067 10.5 3 10.5L3 10L3 9.5C1.61929 9.5 0.5 8.38071 0.5 7L0 7Z" fill="currentColor"/>
</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>
)

View File

@@ -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 + 6 figma extracts)', () => {
expect(iconNames.length).toBe(49)
it('exports the full P-I set (43 deepsuite + 6 figma extracts + 1 hand-authored)', () => {
expect(iconNames.length).toBe(50)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {

View File

@@ -147,6 +147,11 @@ export function apply(ctx: Context): void {
ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message })
return
case 'disarm':
// TODO(disarm-reason): `GoalRoundOutcome.disarm.reason` carries a
// specific string (durability-failed/disposed/interrupted) that is
// produced in `classifyGoalRound()` but discarded here. Either log it
// or remove it from the variant to stop dead payload being carried
// through the result type.
ctx.goals.disarm(state.agent)
return
/* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */

View File

@@ -64,7 +64,19 @@ export interface ResolvedConfig {
defaultMaxGoalRounds: number
}
/** One accepted mutation waiting to enter or be observed in the session log. */
/**
* One accepted mutation waiting to enter or be observed in the session log.
*
* TODO(pending-fifo): The pending FIFO + `applied` flag + `sameChange` match +
* `observedSeq` tracking implements a distributed-transaction-style reconciliation
* protocol over what is purely synchronous, process-local state. In `commit()` there
* is no yield point between `agent.inject()` and the `pending.applied` guard (L509),
* so the guard is always taken — the flag is never `true` at that point. The same
* outcome is achievable by folding the injected event directly after `inject()`
* instead of deferring to `sync()`. This would eliminate `PendingGoalChange`,
* `GoalCache.pending`, `GoalCache.observedSeq`, `sameChange()`, the `applied` flag,
* the `catch` rollback, and the reconciliation branch in `sync()` (~40 lines).
*/
interface PendingGoalChange {
readonly change: GoalChangeMeta
readonly activation: GoalActivation

View File

@@ -0,0 +1,112 @@
/**
* goals domain zod schemas.
*/
import { z } from 'zod'
import type { Wire } from './rpc.schema.ts'
import type { GoalRef, GoalView, RequestPayload, ResponseValue } from './index.ts'
/** GoalRef schema. */
export const goalRefSchema = z.object({
id: z.string(),
revision: z.number().int().positive(),
}) as unknown as z.ZodType<Wire<GoalRef>>
/** Goal block reason schema. */
export const goalBlockReasonSchema = z.object({
code: z.string(),
message: z.string(),
})
/** GoalView schema. */
export const goalViewSchema = z.object({
id: z.string(),
revision: z.number().int().positive(),
objective: z.string(),
phase: z.union([z.literal('active'), z.literal('paused'), z.literal('blocked'), z.literal('complete')]),
blockedReason: goalBlockReasonSchema.optional(),
maxGoalRounds: z.number().int().positive(),
roundsStarted: z.number().int().nonnegative(),
createdAt: z.number(),
updatedAt: z.number(),
activation: z.union([z.literal('armed'), z.literal('disarmed')]),
}) as unknown as z.ZodType<Wire<GoalView>>
/** goal.get request payload. */
export const goalGetRequestSchema = z.object({
sessionId: z.string(),
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.get'>>>
/** goal.get response value. */
export const goalGetValueSchema = z.object({
goal: goalViewSchema.nullable(),
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.get'>>>
/** goal.create request payload. */
export const goalCreateRequestSchema = z.object({
sessionId: z.string(),
objective: z.string().min(1),
maxGoalRounds: z.number().int().positive().optional(),
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.create'>>>
/** goal.create response value. */
export const goalCreateValueSchema = z.object({
goal: goalViewSchema,
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.create'>>>
/** goal.edit request payload. */
export const goalEditRequestSchema = z.object({
sessionId: z.string(),
ref: goalRefSchema,
objective: z.string().min(1).optional(),
maxGoalRounds: z.number().int().positive().optional(),
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.edit'>>>
/** goal.edit response value. */
export const goalEditValueSchema = z.object({
goal: goalViewSchema,
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.edit'>>>
/** goal.pause request payload. */
export const goalPauseRequestSchema = z.object({
sessionId: z.string(),
ref: goalRefSchema,
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.pause'>>>
/** goal.pause response value. */
export const goalPauseValueSchema = z.object({
goal: goalViewSchema,
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.pause'>>>
/** goal.resume request payload. */
export const goalResumeRequestSchema = z.object({
sessionId: z.string(),
ref: goalRefSchema,
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.resume'>>>
/** goal.resume response value. */
export const goalResumeValueSchema = z.object({
goal: goalViewSchema,
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.resume'>>>
/** goal.complete request payload. */
export const goalCompleteRequestSchema = z.object({
sessionId: z.string(),
ref: goalRefSchema,
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.complete'>>>
/** goal.complete response value. */
export const goalCompleteValueSchema = z.object({
goal: goalViewSchema,
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.complete'>>>
/** goal.clear request payload. */
export const goalClearRequestSchema = z.object({
sessionId: z.string(),
ref: goalRefSchema,
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.clear'>>>
/** goal.clear response value. */
export const goalClearValueSchema = z.object({
cleared: z.literal(true),
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.clear'>>>

View File

@@ -0,0 +1,88 @@
/**
* goals domain contract. Method signatures are the source of truth:
* unary methods take the RpcRequest<P> narrow form and the impl echoes rpcId.
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** Identifies one goal across its durable revisions. */
export type GoalId = Branded<'GoalId'>
/** Compare-and-set identity for one exact goal revision. */
export interface GoalRef {
readonly id: GoalId
readonly revision: number
}
/** Durable continuation phase. */
export type GoalPhase =
| 'active'
| 'paused'
| 'blocked'
| 'complete'
/** Machine-routable and human-readable explanation for a blocked goal. */
export interface GoalBlockReason {
readonly code: string
readonly message: string
}
/** Whether this live process may automatically continue an active goal. */
export type GoalActivation = 'armed' | 'disarmed'
/** Current goal projection, including values derived from the session log. */
export interface GoalView {
readonly id: GoalId
readonly revision: number
readonly objective: string
readonly phase: GoalPhase
readonly blockedReason?: GoalBlockReason
readonly maxGoalRounds: number
readonly roundsStarted: number
readonly createdAt: number
readonly updatedAt: number
readonly activation: GoalActivation
}
/** Input whose omitted round cap is resolved by the service configuration. */
export interface CreateGoalRequest {
readonly objective: string
readonly maxGoalRounds?: number
}
/** Fields changed by an edit; at least one must be present. */
export interface EditGoalRequest {
readonly objective?: string
readonly maxGoalRounds?: number
}
/** Goal-domain unary methods. */
export interface GoalsApi {
/** Read the current goal for one session. Returns null when no goal is current. */
get(request: RpcRequest<{ sessionId: string }>): Promise<RpcResponse<{ goal: GoalView | null }>>
/** Create and arm a goal. */
create(request: RpcRequest<{ sessionId: string; objective: string; maxGoalRounds?: number }>):
Promise<RpcResponse<{ goal: GoalView }>>
/** Edit objective and/or round cap without changing phase. */
edit(request: RpcRequest<{ sessionId: string; ref: GoalRef; objective?: string; maxGoalRounds?: number }>):
Promise<RpcResponse<{ goal: GoalView }>>
/** Pause an active goal and disarm automatic continuation. */
pause(request: RpcRequest<{ sessionId: string; ref: GoalRef }>):
Promise<RpcResponse<{ goal: GoalView }>>
/** Resume and arm a stopped goal. */
resume(request: RpcRequest<{ sessionId: string; ref: GoalRef }>):
Promise<RpcResponse<{ goal: GoalView }>>
/** Mark a current non-complete goal complete and disarm it. */
complete(request: RpcRequest<{ sessionId: string; ref: GoalRef }>):
Promise<RpcResponse<{ goal: GoalView }>>
/** Clear the current goal while retaining a durable tombstone and history. */
clear(request: RpcRequest<{ sessionId: string; ref: GoalRef }>):
Promise<RpcResponse<{ cleared: true }>>
}

View File

@@ -7,6 +7,7 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { EventsApi } from './events.ts'
import type { GoalsApi } from './goals.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
@@ -14,6 +15,7 @@ export interface ApiProxy {
sessions: SessionsApi
host: HostApi
events: EventsApi
goals: GoalsApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
}
@@ -22,6 +24,7 @@ export interface ApiProxy {
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
export type { HostApi } from './host.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, CreateGoalRequest, EditGoalRequest } from './goals.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

View File

@@ -6,6 +6,7 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { GoalsApi } from './goals.ts'
import type { RpcResponse } from './rpc.ts'
/** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */
@@ -16,6 +17,13 @@ export interface RpcMethodMap {
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'goal.get': GoalsApi['get']
'goal.create': GoalsApi['create']
'goal.edit': GoalsApi['edit']
'goal.pause': GoalsApi['pause']
'goal.resume': GoalsApi['resume']
'goal.complete': GoalsApi['complete']
'goal.clear': GoalsApi['clear']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

View File

@@ -21,6 +21,15 @@ import {
sessionListValueSchema,
sessionPromptValueSchema,
} from '../api/sessions.schema.ts'
import {
goalGetValueSchema,
goalCreateValueSchema,
goalEditValueSchema,
goalPauseValueSchema,
goalResumeValueSchema,
goalCompleteValueSchema,
goalClearValueSchema,
} from '../api/goals.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
@@ -52,6 +61,15 @@ export interface IApiClient {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
}
goals: {
get(payload: RequestPayload<'goal.get'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.get'>>>
create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.create'>>>
edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.edit'>>>
pause(payload: RequestPayload<'goal.pause'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.pause'>>>
resume(payload: RequestPayload<'goal.resume'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.resume'>>>
complete(payload: RequestPayload<'goal.complete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.complete'>>>
clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.clear'>>>
}
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
}
@@ -67,6 +85,13 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'goal.get': goalGetValueSchema,
'goal.create': goalCreateValueSchema,
'goal.edit': goalEditValueSchema,
'goal.pause': goalPauseValueSchema,
'goal.resume': goalResumeValueSchema,
'goal.complete': goalCompleteValueSchema,
'goal.clear': goalClearValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -253,6 +278,16 @@ export abstract class AbstractApiClient implements IApiClient {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
}
readonly goals: IApiClient['goals'] = {
get: (payload, signal) => this.callUnary('goal.get', payload, signal),
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
pause: (payload, signal) => this.callUnary('goal.pause', payload, signal),
resume: (payload, signal) => this.callUnary('goal.resume', payload, signal),
complete: (payload, signal) => this.callUnary('goal.complete', payload, signal),
clear: (payload, signal) => this.callUnary('goal.clear', payload, signal),
}
readonly events: IApiClient['events'] = {
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),

View File

@@ -22,6 +22,15 @@ import {
sessionPromptRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import {
goalGetRequestSchema,
goalCreateRequestSchema,
goalEditRequestSchema,
goalPauseRequestSchema,
goalResumeRequestSchema,
goalCompleteRequestSchema,
goalClearRequestSchema,
} from '../api/goals.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -44,6 +53,13 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'goal.get': { schema: goalGetRequestSchema, invoke: (api, r) => api.goals.get(r) },
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) },
'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */

View File

@@ -21,9 +21,12 @@ function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']>
host?: Partial<ApiProxy['host']>
events?: Partial<ApiProxy['events']>
goals?: Partial<ApiProxy['goals']>
respond?: ApiProxy['respond']
} = {}): ApiProxy {
async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
return {
sessions: {
list: r => ok(r, { items: [] }),
@@ -34,6 +37,16 @@ function scriptedApi(overrides: {
...overrides.sessions,
},
host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },
goals: {
get: err,
create: err,
edit: err,
pause: err,
resume: err,
complete: err,
clear: err,
...overrides.goals,
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
}

View File

@@ -42,6 +42,29 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
},
},
goals: {
async get(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async create(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async edit(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async pause(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async resume(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async complete(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async clear(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),
host: (_request, signal) => stream(hostFrames, signal),