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:
40 files changed
+1358
-28
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user