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:
_Kerman
2026-07-22 20:51:44 +08:00
parent 0681ac47de
commit 923535fa7a
40 changed files with 1358 additions and 28 deletions

View File

@@ -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
}

View File

@@ -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> = {

View File

@@ -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>
)
}

View File

@@ -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. */

View File

@@ -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'

View File

@@ -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}

View File

@@ -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;
}

View 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>
)
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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
}

View 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')
})
})

View File

@@ -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
}

View File

@@ -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', () => {