瀏覽代碼

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).
imccyu 1 月之前
父節點
當前提交
6e950940ee

+ 70 - 0
packages/client/ui-goal/package.json

@@ -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"
+  ]
+}

+ 120 - 0
packages/client/ui-goal/src/client/GoalBar.module.css

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

+ 162 - 0
packages/client/ui-goal/src/client/GoalBar.tsx

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

+ 15 - 0
packages/client/ui-goal/src/client/IconSparkle16.tsx

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

+ 81 - 0
packages/client/ui-goal/src/client/index.ts

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

+ 26 - 0
packages/client/ui-goal/src/client/slots.ts

@@ -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 - 0
packages/client/ui-goal/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 9 - 0
packages/client/ui-goal/src/index.ts

@@ -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 - 0
packages/client/ui-goal/src/invariant.ts

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

+ 170 - 0
packages/client/ui-goal/tests/goalbar.spec.tsx

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

+ 36 - 0
packages/client/ui-goal/tsconfig.json

@@ -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 - 0
packages/client/ui-goal/tsdown.config.ts

@@ -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 - 0
pnpm-lock.yaml

@@ -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 - 0
tsconfig.client.json

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