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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
122
packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx
Normal file
122
packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
116
packages/client/ui-conversation/tests/goalbar.spec.tsx
Normal file
116
packages/client/ui-conversation/tests/goalbar.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user