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:
@@ -15,7 +15,7 @@ import type {
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
|
||||
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
|
||||
import type { ConversationInjected, DetailsInjected, EmptyStateInjected, GoalBarActions } from './contract/slots.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
import { childSessionScope, registerChat } from './chat/register.ts'
|
||||
@@ -153,6 +153,11 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
return createElement(Fragment, null, ...children)
|
||||
},
|
||||
goalActions: {
|
||||
onEdit: (objective) => { void session.editGoal(objective) },
|
||||
onResume: () => { void session.resumeGoal() },
|
||||
onClear: () => { void session.clearGoal() },
|
||||
} satisfies GoalBarActions,
|
||||
}
|
||||
return injected
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconSparkle16, IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolViewProps } from '../contract/toolview.ts'
|
||||
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
|
||||
/** Variant leading icons (figma table). */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Local sparkle icon for the Others tool-row variant (figma 43:31850 leading
|
||||
// glyph is an SF Symbols "sparkles" text glyph — not extractable as vector
|
||||
// data, so this is a hand-authored three-star approximation). Lives here
|
||||
// rather than ui-primitives until the exact glyph is exported and adopted
|
||||
// into the ic_ds_* family.
|
||||
|
||||
export function IconSparkle16({ size = 16, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
|
||||
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
|
||||
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,16 @@ import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-w
|
||||
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
|
||||
|
||||
/** Goal strip callbacks (the docked GoalBar's verb set). State is read from useSession. */
|
||||
export interface GoalBarActions {
|
||||
/** Replace the current goal's objective. */
|
||||
onEdit(objective: string): void
|
||||
/** Resume a paused goal. */
|
||||
onResume(): void
|
||||
/** Clear the current goal (tombstone). */
|
||||
onClear(): void
|
||||
}
|
||||
|
||||
/** Injected share of the conversation slot (assembled by apply's inject factory). */
|
||||
export interface ConversationInjected {
|
||||
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
|
||||
@@ -38,6 +48,8 @@ export interface ConversationInjected {
|
||||
}
|
||||
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
|
||||
renderView: (entry: ViewEntry) => ReactNode
|
||||
/** Goal callbacks (undefined = goal feature not available). State is read from useSession. */
|
||||
goalActions?: GoalBarActions
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: owner share & standard share & injected share. */
|
||||
|
||||
@@ -22,7 +22,7 @@ export type {
|
||||
} from './contract/toolview.ts'
|
||||
export type {
|
||||
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, GoalBarActions,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
|
||||
@@ -10,6 +10,8 @@ import clsx from 'clsx'
|
||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import { GoalBar } from './GoalBar.tsx'
|
||||
import type { GoalBarProps } from './GoalBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/**
|
||||
@@ -20,7 +22,7 @@ import css from './ConversationRoot.module.css'
|
||||
export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
|
||||
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, goalActions,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
@@ -33,6 +35,7 @@ export function ConversationRoot({
|
||||
const removed = useSession(s => (s as { removed: boolean }).removed)
|
||||
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
|
||||
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
|
||||
const goal = useSession(s => (s as { goal: unknown }).goal)
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
@@ -87,6 +90,11 @@ export function ConversationRoot({
|
||||
{active !== undefined && renderView(active)}
|
||||
</div>
|
||||
|
||||
{/* GoalBar docks directly above the composer (its CSS mirrors the
|
||||
composer's horizontal geometry and tucks under the card's top edge). */}
|
||||
{goalActions !== undefined && (
|
||||
<GoalBar goal={goal as GoalBarProps['goal']} {...goalActions} />
|
||||
)}
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={running}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/* GoalBar: the goal strip docked above the composer card. The dock mirrors
|
||||
InputBar's horizontal geometry (32px side padding, 776px centered cap)
|
||||
plus the mock's 12px inset, so the bar's edges land 12px inside the
|
||||
composer card's edges in both the capped and the squeezed regimes. The
|
||||
negative bottom margin eats InputBar's 8px top padding and tucks the
|
||||
bar's square bottom edge 2px under the composer card's top edge (the
|
||||
card, later in DOM order, paints over it). All states share one fixed
|
||||
38px height so switching between them never resizes the strip. */
|
||||
|
||||
.dock {
|
||||
padding: 0 44px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
max-width: 752px;
|
||||
height: 38px;
|
||||
margin: 0 auto -10px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
/* Translucent hover gray doubles as the mock's #F5F6F7 over the white
|
||||
base and lifts the strip off the composer card in dark mode. */
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.objective {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Inline edit form ---- */
|
||||
|
||||
.objectiveInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.objectiveInput:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.objectiveInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* ---- Icon actions ---- */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.iconBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
122
packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx
Normal file
122
packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GoalBar: the goal indicator docked directly above the message composer
|
||||
* (rounded-top strip tucked under the composer card's top edge). A present
|
||||
* goal shows a sparkle, a phase label, the truncated objective, and icon
|
||||
* actions — resume when paused, edit (inline form in the same strip), and
|
||||
* clear. Goal creation lives on the `/goal` command, not here: loading
|
||||
* (undefined), no goal (null), and complete goals render nothing.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GoalBarActions } from '../contract/slots.ts'
|
||||
import css from './GoalBar.module.css'
|
||||
|
||||
export interface GoalBarProps extends GoalBarActions {
|
||||
/** Current goal state; undefined = still loading, null = no goal set. */
|
||||
goal: GoalView | null | undefined
|
||||
}
|
||||
|
||||
/** Strip labels per visible phase; complete goals render nothing. */
|
||||
const PHASE_LABELS = {
|
||||
active: 'Ongoing Goal',
|
||||
paused: 'Paused Goal',
|
||||
blocked: 'Blocked Goal',
|
||||
} as const
|
||||
|
||||
export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
// A new goal identity (cleared/completed/replaced externally) invalidates the local edit
|
||||
// state: without the reset a surviving draft's Enter would write over the NEW goal.
|
||||
const goalId = goal?.id
|
||||
useEffect(() => {
|
||||
setEditing(false)
|
||||
}, [goalId])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed === '') return
|
||||
onEdit(trimmed)
|
||||
setEditing(false)
|
||||
}, [draft, onEdit])
|
||||
|
||||
// Loading, absent, and complete goals have no strip at all.
|
||||
if (goal === undefined || goal === null || goal.phase === 'complete') return null
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={css.dock}>
|
||||
<div className={css.bar}>
|
||||
<input
|
||||
className={css.objectiveInput}
|
||||
type="text"
|
||||
aria-label="Goal objective"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleEdit()
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={handleEdit}
|
||||
disabled={draft.trim() === ''}
|
||||
title="Save goal"
|
||||
aria-label="Save goal"
|
||||
>
|
||||
<IconCheckOutline16 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => setEditing(false)}
|
||||
title="Cancel edit"
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined
|
||||
return (
|
||||
<div className={css.dock}>
|
||||
<div className={css.bar} title={title}>
|
||||
<span className={css.sparkle}><IconSparkle16 /></span>
|
||||
<span className={css.label}>{PHASE_LABELS[goal.phase]}</span>
|
||||
<span className={css.objective}>{goal.objective}</span>
|
||||
<div className={css.actions}>
|
||||
{goal.phase === 'paused' && (
|
||||
<button type="button" className={css.iconBtn} onClick={onResume} title="Resume goal" aria-label="Resume goal">
|
||||
<IconPlayOutline16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconBtn}
|
||||
onClick={() => { setDraft(goal.objective); setEditing(true) }}
|
||||
title="Edit goal"
|
||||
aria-label="Edit goal"
|
||||
>
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
<button type="button" className={css.iconBtn} onClick={onClear} title="Clear goal" aria-label="Clear goal">
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
goal: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
goal: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
|
||||
116
packages/client/ui-conversation/tests/goalbar.spec.tsx
Normal file
116
packages/client/ui-conversation/tests/goalbar.spec.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
// GoalBar behavior: the docked strip above the composer — phase labels,
|
||||
// inline edit form, and resume/clear icon actions — driven purely through
|
||||
// props, no wire. Loading, absent, and complete goals render nothing.
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { GoalBar } from '../src/client/skeleton/GoalBar.tsx'
|
||||
import type { GoalBarActions } from '../src/client/contract/slots.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function makeGoal(over: Partial<GoalView> = {}): GoalView {
|
||||
return {
|
||||
id: 'g1' as GoalView['id'],
|
||||
revision: 1,
|
||||
objective: 'Ship the redesign',
|
||||
phase: 'active',
|
||||
maxGoalRounds: 4,
|
||||
roundsStarted: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
activation: 'armed',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function makeActions(): { [K in keyof GoalBarActions]: ReturnType<typeof vi.fn<GoalBarActions[K]>> } {
|
||||
return {
|
||||
onEdit: vi.fn<GoalBarActions['onEdit']>(),
|
||||
onResume: vi.fn<GoalBarActions['onResume']>(),
|
||||
onClear: vi.fn<GoalBarActions['onClear']>(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('GoalBar', () => {
|
||||
it('renders nothing while loading, absent, or when the goal is complete', () => {
|
||||
const actions = makeActions()
|
||||
const loading = render(<GoalBar goal={undefined} {...actions} />)
|
||||
expect(loading.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const absent = render(<GoalBar goal={null} {...actions} />)
|
||||
expect(absent.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const complete = render(<GoalBar goal={makeGoal({ phase: 'complete' })} {...actions} />)
|
||||
expect(complete.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('active goal: sparkle, "Ongoing Goal", truncated objective, edit and clear actions', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('Ship the redesign')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
|
||||
expect(actions.onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
const box = screen.getByRole('textbox', { name: 'Goal objective' })
|
||||
expect((box as HTMLInputElement).value).toBe('Ship the redesign')
|
||||
|
||||
fireEvent.change(box, { target: { value: ' ' } })
|
||||
expect((screen.getByRole('button', { name: 'Save goal' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
fireEvent.change(box, { target: { value: 'Ship v2' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(actions.onEdit).toHaveBeenCalledWith('Ship v2')
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Esc cancels the edit without calling onEdit', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Goal objective' }), { key: 'Escape' })
|
||||
expect(actions.onEdit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('paused goal: "Paused Goal" with a resume action before edit', () => {
|
||||
const actions = makeActions()
|
||||
render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
|
||||
expect(screen.getByText('Paused Goal')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
|
||||
expect(actions.onResume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a new goal identity drops the edit form (no stale draft over the new goal)', () => {
|
||||
const actions = makeActions()
|
||||
const { rerender } = render(<GoalBar goal={makeGoal()} {...actions} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'stale draft' } })
|
||||
|
||||
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalView['id'], objective: 'New goal' })} {...actions} />)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('New goal')).toBeTruthy()
|
||||
|
||||
rerender(<GoalBar goal={null} {...actions} />)
|
||||
expect(screen.queryByText('Ongoing Goal')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocked goal: "Blocked Goal" with the block reason as the strip tooltip', () => {
|
||||
const actions = makeActions()
|
||||
const goal = makeGoal({ phase: 'blocked', blockedReason: { code: 'stalled', message: 'No progress in 3 rounds' } })
|
||||
render(<GoalBar goal={goal} {...actions} />)
|
||||
expect(screen.getByText('Blocked Goal')).toBeTruthy()
|
||||
expect(screen.getByText('Blocked Goal').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,92 @@ describe('ConversationRoot', () => {
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('queue')
|
||||
})
|
||||
|
||||
it('renders GoalBar when goalActions and an active goal are provided', () => {
|
||||
const goal = {
|
||||
id: 'g1' as never,
|
||||
revision: 1,
|
||||
objective: 'test-objective',
|
||||
phase: 'active' as const,
|
||||
maxGoalRounds: 256,
|
||||
roundsStarted: 0,
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
activation: 'armed' as const,
|
||||
}
|
||||
const store = createSnapshotStore<FakeSnapshot & { goal: typeof goal }>({
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, goal,
|
||||
})
|
||||
const useSession = bindSnapshotSelector(store) as unknown as UseSession
|
||||
const activeStore = createSnapshotStore<string | undefined>('chat')
|
||||
const views = [view('chat', 'Chat')]
|
||||
render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useAncestry={() => []}
|
||||
views={{
|
||||
list: () => views,
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined}
|
||||
composer={{
|
||||
useDraft: () => '',
|
||||
setDraft: () => {},
|
||||
send: () => {},
|
||||
stop: () => {},
|
||||
}}
|
||||
actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }}
|
||||
renderView={() => null}
|
||||
goalActions={{
|
||||
onEdit: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
}}
|
||||
/>)
|
||||
expect(screen.getByText('Ongoing Goal')).toBeTruthy()
|
||||
expect(screen.getByText('test-objective')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides GoalBar when goalActions is undefined (even with an active goal in the store)', () => {
|
||||
const goal = {
|
||||
id: 'g1' as never,
|
||||
revision: 1,
|
||||
objective: 'test-objective',
|
||||
phase: 'active' as const,
|
||||
maxGoalRounds: 256,
|
||||
roundsStarted: 0,
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
activation: 'armed' as const,
|
||||
}
|
||||
const store = createSnapshotStore<FakeSnapshot & { goal: typeof goal }>({
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, goal,
|
||||
})
|
||||
const useSession = bindSnapshotSelector(store) as unknown as UseSession
|
||||
render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useAncestry={() => []}
|
||||
views={{
|
||||
list: () => [],
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
useActiveView={() => 'chat' as ViewId}
|
||||
composer={{
|
||||
useDraft: () => '',
|
||||
setDraft: () => {},
|
||||
send: () => {},
|
||||
stop: () => {},
|
||||
}}
|
||||
actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }}
|
||||
renderView={() => null}
|
||||
/>)
|
||||
expect(screen.queryByText('Ongoing Goal')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel', () => {
|
||||
|
||||
Reference in New Issue
Block a user