feat(web): ui-goal package — GoalBar over the goal session projection

New client plugin package. The GoalBar component migrates verbatim from the
original PR (phase labels, inline edit form, resume-when-paused, pending
lockout, inline errors); its data path is rebuilt for the projection model:

- GoalDock adapter reads useProjection('goal') from the standard kit — no
  controller, no store, no refresh chain, no event listener (the original
  PR's Session goal fields, write-revision fence, and coalesced refetch all
  dissolve into the value store's seq rule).
- The inject face carries only the three mutation verbs; each reads the CAS
  ref from the session's current projected value at call time and maps the
  settled RPC result inline (callbacks from inject, live state from
  useProjection).
- Seats at conversation.input.dock order 1 (the slot contract names GoalBar
  as a resident of this strip), below TodoPanel/QueueDock, tucked against
  the composer; hero phase renders nothing for free.
- IconSparkle16 is package-local (sole consumer) — ui-primitives keeps only
  the shared action icons.
- goalbar.spec migrates with GoalSnapshot props (activation is process-local
  and absent from the projection; the strip renders durable phase only).
This commit is contained in:
imccyu
2026-07-28 21:40:49 +08:00
parent c991f76cef
commit 6e950940ee
14 changed files with 770 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@deepseek-ai/dsh-client-ui-goal",
"description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}
@@ -0,0 +1,120 @@
/* 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;
}
.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 {
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;
}
@@ -0,0 +1,162 @@
/**
* GoalBar: the goal indicator docked above the message composer (input dock
* strip). 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. Live state arrives as the projected whole snapshot; the verbs are
* the injected face.
*/
import { useCallback, useEffect, useState } from 'react'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { IconSparkle16 } from './IconSparkle16.tsx'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
import css from './GoalBar.module.css'
export interface GoalBarProps extends GoalBarActions {
/** Current goal snapshot; undefined = capability absent or loading, null = no goal set. */
goal: GoalSnapshot | 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('')
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(async () => {
const trimmed = draft.trim()
if (trimmed === '') return
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} data-goal-bar>
<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') 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={() => { void handleEdit() }}
disabled={pending || draft.trim() === ''}
title="Save goal"
aria-label="Save goal"
>
<IconCheckOutline16 />
</button>
<button
type="button"
className={css.iconBtn}
onClick={() => setEditing(false)}
disabled={pending}
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} 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} 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} disabled={pending} onClick={() => { void runAction(onClear) }} title="Clear goal" aria-label="Clear goal">
<IconTrashOutline16 />
</button>
</div>
</div>
</div>
)
}
/** Full props of the dock entry: InputZone owner share + session standard kit + injected verbs. */
export type GoalDockProps = import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.dock'> & GoalBarActions
/** Dock adapter: reads the host-computed 'goal' projection (whole value; absent or null renders nothing). */
export function GoalDock({ useProjection, onEdit, onResume, onClear }: GoalDockProps) {
const projection = useProjection('goal')
return (
<GoalBar
goal={projection === undefined ? undefined : projection === null ? null : projection.goal}
onEdit={onEdit}
onResume={onResume}
onClear={onClear}
/>
)
}
@@ -0,0 +1,15 @@
// 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>
)
}
@@ -0,0 +1,81 @@
/**
* Goal surface plugin, browser half: the GoalBar entry in the
* conversation.input.dock strip. Projection-mode surface — the live goal
* arrives through `useProjection('goal')` (seeded by the history tail page,
* updated by session/projection frames), so this plugin owns no store, no
* refresh chain, and no event listener. The inject face carries only the
* three mutation verbs (edit/resume/clear over the goal.* wire domain);
* their CAS ref reads the session's current projected value at call time.
* Goal creation stays on the /goal host command.
*/
import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet).
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
import { GoalDock } from './GoalBar.tsx'
export { GoalBar, GoalDock } from './GoalBar.tsx'
export type { GoalActionResult, GoalBarActions } from './slots.ts'
/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs. */
export const inject = ['slots', 'sessions', 'connection']
/** Map one settled RPC result onto the strip's inline-render shape. */
function settle<T>(result: RpcResult<T>): GoalActionResult {
if (result.ok) return { ok: true }
return { ok: false, error: { code: result.error.code, message: result.error.message } }
}
/**
* Client plugin body: the GoalBar dock entry with its mutation verbs.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const { goals } = (ctx.get('connection') as ConnectionHandle).api
// Conditional mount: 'conversation.input.dock' is declared by the
// conversation entry; the conversation service being up is the
// registration-safe signal (the TodoDock/QueueDock seam).
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
const sessions = scope.sessions
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
const refOf = (sessionId: SessionId): GoalRef | undefined => {
const projection = sessions.binding(sessionId)?.session.projections.get('goal') as GoalProjection | null | undefined
if (projection == null) return undefined
return { id: projection.goal.id, revision: projection.goal.revision }
}
const noCurrentGoal: GoalActionResult = {
ok: false,
error: { code: 'no-current-goal', message: 'no current goal to mutate' },
}
scope.effect(() => scope.slots.register({
name: 'conversation.input.dock',
id: 'goal',
order: 1,
inject: (sessionId): GoalBarActions => ({
onEdit: async (objective) => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.edit({ sessionId, ref, objective })).result)
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.resume({ sessionId, ref })).result)
},
onClear: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.clear({ sessionId, ref })).result)
},
}),
}, GoalDock), 'ui-goal: GoalBar dock registration')
})
}
@@ -0,0 +1,26 @@
/**
* GoalBar's injected face. The target 'conversation.input.dock' slot is
* declared (children table) and typed by ui-conversation; this package only
* contributes the entry, so no SlotMap merge lives here. The live goal value
* is NOT part of this face — it arrives through `useProjection('goal')`
* (the framework standard kit); inject carries only the mutation verbs
* (callbacks from inject, live state from useProjection).
*/
/** Settled outcome of one goal mutation, rendered inline by the strip. */
export type GoalActionResult =
| { ok: true }
| { ok: false; error: { code: string; message: string } }
/** Injected business face of the GoalBar dock entry: the mutation verbs. */
export interface GoalBarActions {
/**
* Replace the current goal's objective (CAS on the projected ref).
* @param objective - replacement objective text.
*/
onEdit(objective: string): Promise<GoalActionResult>
/** Resume a paused goal. */
onResume(): Promise<GoalActionResult>
/** Clear the current goal (tombstone). */
onClear(): Promise<GoalActionResult>
}
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
+9
View File
@@ -0,0 +1,9 @@
/**
* Goal surface plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader; the browser half
* ships via exports["./client"], discovered through the package.json
* dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}
+32
View File
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-goal`.
* @module @deepseek-ai/dsh-client-ui-goal/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-goal'
/** Cordis companion plugin name. */
export const name = 'client-ui-goal-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a single GoalBar dock registration whose disposal is
* proven by the HMR-safety spec — the plugin owns no store (state arrives on
* the goal projection), emits no cordis events, and holds no cross-plugin
* mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,170 @@
// @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, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import { GoalBar } from '../src/client/GoalBar.tsx'
import type { GoalBarActions } from '../src/client/slots.ts'
afterEach(cleanup)
function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
return {
id: 'g1' as GoalSnapshot['id'],
revision: 1,
objective: 'Ship the redesign',
phase: 'active',
maxGoalRounds: 4,
...over,
}
}
function makeActions() {
return {
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', () => {
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', async () => {
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')
await waitFor(() => { 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('the cancel button exits the form and drops the draft (re-edit starts from the objective)', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal()} {...actions} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'abandoned draft' } })
fireEvent.click(screen.getByRole('button', { name: 'Cancel edit' }))
expect(actions.onEdit).not.toHaveBeenCalled()
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
expect((screen.getByRole('textbox', { name: 'Goal objective' }) as HTMLInputElement).value).toBe('Ship the redesign')
})
it('Enter with a blank draft neither saves nor closes the form', () => {
const actions = makeActions()
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: ' ' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(actions.onEdit).not.toHaveBeenCalled()
expect(screen.getByRole('textbox', { name: 'Goal objective' })).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 GoalSnapshot['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')
})
it('blocked goal without a reason carries no tooltip', () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal({ phase: 'blocked' })} {...actions} />)
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()
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../goal/goal"
},
{
"path": "../../support/invariants"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js'])
+39
View File
@@ -998,6 +998,45 @@ importers:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-goal:
devDependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-goal':
specifier: workspace:^
version: link:../../goal/goal
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@testing-library/react':
specifier: ^16.1.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/react':
specifier: ~18.3.1
version: 18.3.31
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages/client/ui-layout:
devDependencies:
'@deepseek-ai/dsh-client-locale':
+1
View File
@@ -42,6 +42,7 @@
{ "path": "./packages/client/ui-command" },
{ "path": "./packages/client/ui-skill" },
{ "path": "./packages/client/ui-subagent" },
{ "path": "./packages/client/ui-goal" },
{ "path": "./packages/client/ui-model" },
{ "path": "./packages/client/ui-question" },
{ "path": "./packages/client/ui-trajectory" },