fix(gui): address goal UI review feedback
This commit is contained in:
@@ -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: {} }),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 revision(agent-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 failed(internal)')
|
||||
|
||||
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 failed(agent-busy)')
|
||||
expect(screen.getByText('Ship the redesign')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user