fix(gui): address goal UI review feedback

This commit is contained in:
_Kerman
2026-07-22 22:18:13 +08:00
parent feeea91bf8
commit beec67f187
31 changed files with 325 additions and 220 deletions

View File

@@ -10,7 +10,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
ToolCallView, ToolEventView, ToolResultView, GoalView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
@@ -243,6 +243,11 @@ export function createFixtureApi(): ApiProxy {
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const fixtureGoal: GoalView = {
id: 'fx-goal-1' as GoalView['id'], revision: 1, objective: 'Ship the fixture goal bar',
phase: 'active', maxGoalRounds: 4, roundsStarted: 1, createdAt: 1, updatedAt: 2,
activation: 'armed',
}
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
@@ -424,7 +429,7 @@ export function createFixtureApi(): ApiProxy {
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: {} }),
get: request => ok(request, { goal: request.payload.sessionId === sid('fx-alpha') ? fixtureGoal : null }),
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: {} }),

View File

@@ -308,6 +308,16 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
const goal = await client.goals.get({ sessionId: sid('fx-alpha') })
expect(goal.result).toMatchObject({ ok: true, value: { goal: { objective: 'Ship the fixture goal bar' } } })
expect((await client.goals.get({ sessionId: id })).result).toEqual({ ok: true, value: { goal: null } })
const ref = { id: 'fx-goal-1' as never, revision: 1 }
expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false)
expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false)
expect((await client.goals.pause({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.resume({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false)
})
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {

View File

@@ -63,8 +63,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
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. */
/** Coalesced goal refetch; a trigger received in flight schedules one trailing read. */
private goalFetch: Promise<void> | null = null
private goalFetchPending = false
/** 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
@@ -127,17 +128,27 @@ 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). */
/** Fetch the current goal, coalesced: concurrent triggers share the in-flight get. */
private fetchGoal(): Promise<void> {
if (this.goalFetch !== null) return this.goalFetch
const promise = this.doFetchGoal().finally(() => {
if (this.goalFetch === promise) this.goalFetch = null
})
if (this.goalFetch !== null) {
this.goalFetchPending = true
return this.goalFetch
}
const promise = this.drainGoalFetches().finally(() => { this.goalFetch = null })
this.goalFetch = promise
return promise
}
/** Drain the current read plus one coalesced trailing read for triggers received in flight. */
private async drainGoalFetches(): Promise<void> {
do {
this.goalFetchPending = false
await this.doFetchGoal()
// A goal-change callback can set the flag while doFetchGoal is suspended.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} while (this.goalFetchPending)
}
/** 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> {
@@ -153,18 +164,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/**
* 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 }>> {
/** Execute a goal mutation and publish its successful projection. */
private async updateGoal(
request: () => Promise<{ result: RpcResult<{ goal: GoalView }> }>,
): Promise<RpcResult<{ goal: GoalView }>> {
let result: RpcResult<{ goal: GoalView }>
try {
result = (await this.api.goals.create({
sessionId: this.sessionId, objective, ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
})).result
result = (await request()).result
} catch (error) {
result = transportError(error)
}
@@ -176,6 +182,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* 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 }>> {
return this.updateGoal(() => this.api.goals.create({
sessionId: this.sessionId, objective, ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
}))
}
/**
* Edit this session's goal objective or round cap (CAS with the locally held revision).
* @param objective - replacement objective text; absent leaves it unchanged.
@@ -186,23 +204,22 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
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)
const goal = this.goal
return this.updateGoal(() => this.api.goals.edit({
sessionId: this.sessionId,
ref: { id: goal.id, revision: goal.revision },
...(objective !== undefined ? { objective } : {}),
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
}))
}
/** Apply a phase transition to the current goal. */
private transitionGoal(operation: 'pause' | 'resume' | 'complete'): Promise<RpcResult<{ goal: GoalView }>> {
if (this.goal === null || this.goal === undefined) {
return Promise.resolve({ ok: false, error: { code: 'internal', message: `No goal to ${operation}`, details: {} } })
}
if (result.ok) {
this.goal = result.value.goal
this.goalWriteRev++
this.notifier.markDirty()
}
return result
const ref = { id: this.goal.id, revision: this.goal.revision }
return this.updateGoal(() => this.api.goals[operation]({ sessionId: this.sessionId, ref }))
}
/**
@@ -210,23 +227,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @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
return this.transitionGoal('pause')
}
/**
@@ -234,23 +235,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @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
return this.transitionGoal('resume')
}
/**
@@ -258,23 +243,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @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
return this.transitionGoal('complete')
}
/**

View File

@@ -647,6 +647,12 @@ describe('goal session methods', () => {
expect(session.getSnapshot().goal).toEqual(goal)
})
it('createGoal forwards an explicit round cap', async () => {
const { api, session } = makeSession()
await session.createGoal('bounded', 7)
expect(api.callsOf('goal.create')).toEqual([{ sessionId: SID, objective: 'bounded', maxGoalRounds: 7 }])
})
it('editGoal sends the current ref and updates snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
@@ -661,6 +667,18 @@ describe('goal session methods', () => {
expect(session.getSnapshot().goal).toEqual(edited)
})
it('editGoal can replace only the round cap', async () => {
const { api, session } = makeSession()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const edited = makeGoal({ revision: 2, maxGoalRounds: 9 })
const edit = vi.fn(() => Promise.resolve(ok({ goal: edited })))
api.goals.edit = edit
await session.editGoal(undefined, 9)
expect(edit).toHaveBeenCalledWith({ sessionId: SID, ref: { id: goal.id, revision: 1 }, maxGoalRounds: 9 })
})
it('editGoal returns an error when no goal exists', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
@@ -670,6 +688,15 @@ describe('goal session methods', () => {
expect((r as { error: { code: string } }).error.code).toBe('internal')
})
it('phase mutations and clear return an error when no goal exists', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
for (const mutate of [() => session.pauseGoal(), () => session.resumeGoal(), () => session.completeGoal(), () => session.clearGoal()]) {
expect((await mutate()).ok).toBe(false)
}
})
it('pauseGoal pauses and updates snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
@@ -748,6 +775,14 @@ describe('goal session methods', () => {
expect(session.getSnapshot().goal).toEqual(goal)
})
it('keeps the goal unresolved when the eager fetch returns an RPC error', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([])
api.goals.get = () => Promise.resolve(err({ code: 'internal', message: 'unavailable', details: {} }))
await session.open()
expect(session.getSnapshot().goal).toBeUndefined()
})
const goalChangeEvent = (seq: number, operation: string): SessionEvent =>
at(seq, {
type: 'context/message', surfaceOp: 'append',
@@ -773,7 +808,7 @@ describe('goal session methods', () => {
},
})
it('live goal-change meta triggers one coalesced refetch; window replays never refetch', async () => {
it('live goal-change meta coalesces to one in-flight read plus one trailing read; 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)])
@@ -791,12 +826,14 @@ describe('goal session methods', () => {
})
expect(refetches).toBe(0)
// Two live goal events (change + clear tombstone) coalesce into a single refetch.
// Two live goal events (change + clear tombstone) share the in-flight read, then the
// second trigger schedules a trailing read so an independently ordered GET cannot win.
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(refetches).toBe(2) })
await vi.waitFor(() => { expect(session.getSnapshot().goal).toEqual(goal) })
})
@@ -837,6 +874,11 @@ describe('goal session methods', () => {
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
api.goals.clear = () => Promise.reject(new Error('clear wire down'))
const cleared = await session.clearGoal()
expect(cleared).toMatchObject({ ok: false, error: { code: 'internal', message: 'clear wire down' } })
expect(session.getSnapshot().goal).toEqual(goal)
})
it('a goal.get transport rejection on a live refetch is logged and swallowed', async () => {

View File

@@ -154,9 +154,9 @@ 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() },
onEdit: objective => session.editGoal(objective),
onResume: () => session.resumeGoal(),
onClear: () => session.clearGoal(),
} satisfies GoalBarActions,
}
return injected

View File

@@ -13,14 +13,17 @@ 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'
/** Result shape the goal strip needs to retain drafts and surface action failures. */
export type GoalActionResult = { ok: true } | { ok: false; error: { code: string; message: string } }
/** 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
onEdit(objective: string): Promise<GoalActionResult>
/** Resume a paused goal. */
onResume(): void
onResume(): Promise<GoalActionResult>
/** Clear the current goal (tombstone). */
onClear(): void
onClear(): Promise<GoalActionResult>
}
/** Injected share of the conversation slot (assembled by apply's inject factory). */

View File

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

View File

@@ -51,6 +51,17 @@
white-space: nowrap;
}
.error {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Inline edit form ---- */
.objectiveInput {

View File

@@ -12,7 +12,7 @@ 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 type { GoalActionResult, GoalBarActions } from '../contract/slots.ts'
import css from './GoalBar.module.css'
export interface GoalBarProps extends GoalBarActions {
@@ -30,27 +30,45 @@ const PHASE_LABELS = {
export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
// A new goal identity (cleared/completed/replaced externally) invalidates the local edit
// state: without the reset a surviving draft's Enter would write over the NEW goal.
const goalId = goal?.id
useEffect(() => {
setEditing(false)
setActionError(null)
}, [goalId])
const handleEdit = useCallback(() => {
const handleEdit = useCallback(async () => {
const trimmed = draft.trim()
if (trimmed === '') return
onEdit(trimmed)
setEditing(false)
setPending(true)
setActionError(null)
const result = await onEdit(trimmed)
setPending(false)
if (result.ok) {
setEditing(false)
} else {
setActionError(`${result.error.message}${result.error.code}`)
}
}, [draft, onEdit])
const runAction = useCallback(async (action: () => Promise<GoalActionResult>) => {
setPending(true)
setActionError(null)
const result = await action()
setPending(false)
if (!result.ok) setActionError(`${result.error.message}${result.error.code}`)
}, [])
// Loading, absent, and complete goals have no strip at all.
if (goal === undefined || goal === null || goal.phase === 'complete') return null
if (editing) {
return (
<div className={css.dock}>
<div className={css.dock} data-goal-bar>
<div className={css.bar}>
<input
className={css.objectiveInput}
@@ -59,17 +77,18 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleEdit()
if (e.key === 'Enter') void handleEdit()
if (e.key === 'Escape') setEditing(false)
}}
autoFocus
/>
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
<div className={css.actions}>
<button
type="button"
className={css.iconBtn}
onClick={handleEdit}
disabled={draft.trim() === ''}
onClick={() => { void handleEdit() }}
disabled={pending || draft.trim() === ''}
title="Save goal"
aria-label="Save goal"
>
@@ -79,6 +98,7 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
type="button"
className={css.iconBtn}
onClick={() => setEditing(false)}
disabled={pending}
title="Cancel edit"
aria-label="Cancel edit"
>
@@ -92,27 +112,29 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined
return (
<div className={css.dock}>
<div className={css.dock} data-goal-bar>
<div className={css.bar} title={title}>
<span className={css.sparkle}><IconSparkle16 /></span>
<span className={css.label}>{PHASE_LABELS[goal.phase]}</span>
<span className={css.objective}>{goal.objective}</span>
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
<div className={css.actions}>
{goal.phase === 'paused' && (
<button type="button" className={css.iconBtn} onClick={onResume} title="Resume goal" aria-label="Resume goal">
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} title="Resume goal" aria-label="Resume goal">
<IconPlayOutline16 />
</button>
)}
<button
type="button"
className={css.iconBtn}
disabled={pending}
onClick={() => { setDraft(goal.objective); setEditing(true) }}
title="Edit goal"
aria-label="Edit goal"
>
<IconEditOutline16 />
</button>
<button type="button" className={css.iconBtn} onClick={onClear} title="Clear goal" aria-label="Clear goal">
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onClear) }} title="Clear goal" aria-label="Clear goal">
<IconTrashOutline16 />
</button>
</div>

View File

@@ -63,6 +63,9 @@ async function bench() {
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
editGoal: vi.fn(() => Promise.resolve({ ok: true as const, value: { goal: {} } })),
resumeGoal: vi.fn(() => Promise.resolve({ ok: true as const, value: { goal: {} } })),
clearGoal: vi.fn(() => Promise.resolve({ ok: true as const, value: { cleared: true as const } })),
}
sessionFake.useSelector = bindSnapshotSelector(sessionFake as never)
const scopes = new Map<SessionId, Context>()
@@ -166,6 +169,19 @@ describe('conversation slot inject surface', () => {
await new Promise(r => setTimeout(r, 0))
})
it('goal actions return the runtime mutation results', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
goalActions: import('@deepseek-ai/dsh-client-ui-conversation/client').GoalBarActions
}
expect((await injected.goalActions.onEdit('updated')).ok).toBe(true)
expect((await injected.goalActions.onResume()).ok).toBe(true)
expect((await injected.goalActions.onClear()).ok).toBe(true)
expect(b.sessionFake.editGoal).toHaveBeenCalledWith('updated')
expect(b.sessionFake.resumeGoal).toHaveBeenCalledTimes(1)
expect(b.sessionFake.clearGoal).toHaveBeenCalledTimes(1)
})
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {

View File

@@ -3,7 +3,7 @@
// 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 { cleanup, fireEvent, render, screen, waitFor } 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'
@@ -26,12 +26,12 @@ function makeGoal(over: Partial<GoalView> = {}): GoalView {
}
}
function makeActions(): { [K in keyof GoalBarActions]: ReturnType<typeof vi.fn<GoalBarActions[K]>> } {
function makeActions() {
return {
onEdit: vi.fn<GoalBarActions['onEdit']>(),
onResume: vi.fn<GoalBarActions['onResume']>(),
onClear: vi.fn<GoalBarActions['onClear']>(),
}
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })),
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })),
} satisfies GoalBarActions
}
describe('GoalBar', () => {
@@ -58,7 +58,7 @@ describe('GoalBar', () => {
expect(actions.onClear).toHaveBeenCalledTimes(1)
})
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', () => {
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal()} {...actions} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
@@ -71,7 +71,7 @@ describe('GoalBar', () => {
fireEvent.change(box, { target: { value: 'Ship v2' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(actions.onEdit).toHaveBeenCalledWith('Ship v2')
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
await waitFor(() => { expect(screen.getByText('Ongoing Goal')).toBeTruthy() })
})
it('Esc cancels the edit without calling onEdit', () => {
@@ -144,4 +144,31 @@ describe('GoalBar', () => {
expect(screen.getByText('Blocked Goal')).toBeTruthy()
expect(screen.getByText('Blocked Goal').closest('[title]')).toBeNull()
})
it('keeps the edit draft open and reports a failed save', async () => {
const actions = makeActions()
actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } })
render(<GoalBar goal={makeGoal()} {...actions} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
const box = screen.getByRole('textbox', { name: 'Goal objective' })
fireEvent.change(box, { target: { value: 'retry this draft' } })
fireEvent.click(screen.getByRole('button', { name: 'Save goal' }))
expect((await screen.findByRole('alert')).textContent).toBe('stale revisionagent-busy')
expect((screen.getByRole('textbox', { name: 'Goal objective' }) as HTMLInputElement).value).toBe('retry this draft')
})
it('reports resume and clear failures without hiding the goal', async () => {
const actions = makeActions()
actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } })
const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
expect((await screen.findByRole('alert')).textContent).toBe('resume failedinternal')
actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } })
rerender(<GoalBar goal={makeGoal()} {...actions} />)
fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
expect((await screen.findByRole('alert')).textContent).toBe('clear failedagent-busy')
expect(screen.getByText('Ship the redesign')).toBeTruthy()
})
})

View File

@@ -172,9 +172,9 @@ describe('ConversationRoot', () => {
actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }}
renderView={() => null}
goalActions={{
onEdit: vi.fn(),
onResume: vi.fn(),
onClear: vi.fn(),
onEdit: vi.fn(() => Promise.resolve({ ok: true as const })),
onResume: vi.fn(() => Promise.resolve({ ok: true as const })),
onClear: vi.fn(() => Promise.resolve({ ok: true as const })),
}}
/>)
expect(screen.getByText('Ongoing Goal')).toBeTruthy()