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

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