mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
145 lines
6.3 KiB
TypeScript
145 lines
6.3 KiB
TypeScript
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
|
||
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
|
||
// the next turn/start) — no data of its own, hidden while the list is empty.
|
||
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
|
||
// dock adapter does the selecting, so the panel takes the plain list and stays
|
||
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
|
||
|
||
import { useId, useState } from 'react'
|
||
import type { Context } from 'cordis'
|
||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||
// The domain's client-namespace pure-type outlet: one import edge delivers
|
||
// the `todos` projection-key merge (single source, no consumer-side restated
|
||
// declare) and the payload type. Type-only by construction — the outlet is
|
||
// free of host value imports, so no host Context merge enters this program.
|
||
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||
import css from './TodoPanel.module.css'
|
||
|
||
export interface TodoPanelProps {
|
||
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
|
||
todos: readonly TodoItem[]
|
||
}
|
||
|
||
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
|
||
/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */
|
||
function assertNever(value: never): never {
|
||
throw new Error(`unreachable todo status: ${String(value)}`)
|
||
}
|
||
|
||
/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */
|
||
function CompletedGlyph() {
|
||
return (
|
||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphCompleted}>
|
||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" />
|
||
<path
|
||
d="M10.9631 5.71411L7.70154 8.97571C7.48011 9.19714 7.27736 9.40099 7.09229 9.54993C6.89742 9.70669 6.66314 9.85279 6.3634 9.90027C6.2049 9.92534 6.04339 9.92534 5.88489 9.90027C5.58515 9.85279 5.35087 9.70669 5.15601 9.54993C4.97093 9.40099 4.76818 9.19714 4.54675 8.97571L3.03516 7.46411L3.96313 6.53613L5.47473 8.04773C5.7169 8.28989 5.86196 8.43389 5.97888 8.52795C6.08597 8.61409 6.10875 8.60701 6.08997 8.604C6.11259 8.60758 6.13571 8.60758 6.15833 8.604C6.13954 8.60701 6.16232 8.61409 6.26941 8.52795C6.38633 8.43389 6.53139 8.28989 6.77356 8.04773L10.0352 4.78613L10.9631 5.71411Z"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** In-progress: business-blue ring fading out; CSS spins the svg. */
|
||
function ProgressGlyph() {
|
||
const gradientId = useId()
|
||
return (
|
||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphProgress}>
|
||
<defs>
|
||
<linearGradient id={gradientId} x1="2.5" y1="12" x2="10.5" y2="3.5" gradientUnits="userSpaceOnUse">
|
||
<stop stopColor="currentColor" />
|
||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
<circle cx="7" cy="7" r="6.4" stroke={`url(#${gradientId})`} strokeWidth="1.2" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */
|
||
function PendingGlyph() {
|
||
return (
|
||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphPending}>
|
||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2.4 2.4" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function StatusGlyph({ status }: { status: TodoItem['status'] }) {
|
||
switch (status) {
|
||
case 'completed': return <CompletedGlyph />
|
||
case 'in_progress': return <ProgressGlyph />
|
||
case 'pending': return <PendingGlyph />
|
||
/* v8 ignore next -- closed TodoItem status union */
|
||
default: return assertNever(status)
|
||
}
|
||
}
|
||
|
||
/** Header summary: "<done>/<total> tasks · <n> in progress". */
|
||
function progressLabel(todos: readonly TodoItem[]): string {
|
||
const done = todos.filter(t => t.status === 'completed').length
|
||
const active = todos.filter(t => t.status === 'in_progress').length
|
||
return `${done}/${todos.length} tasks · ${active} in progress`
|
||
}
|
||
|
||
export function TodoPanel({ todos }: TodoPanelProps) {
|
||
const [collapsed, setCollapsed] = useState(true)
|
||
if (todos.length === 0) return null
|
||
|
||
return (
|
||
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
|
||
<div className={css.body}>
|
||
<button
|
||
type="button"
|
||
className={css.header}
|
||
aria-expanded={!collapsed}
|
||
onClick={() => { setCollapsed(v => !v) }}
|
||
>
|
||
<span className={css.title}>To-dos</span>
|
||
<span className={css.progress}>{progressLabel(todos)}</span>
|
||
<span className={css.chevron} aria-hidden>
|
||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||
</span>
|
||
</button>
|
||
{!collapsed && (
|
||
<ul className={css.list}>
|
||
{todos.map(item => (
|
||
<li key={item.content} className={css.item} data-status={item.status}>
|
||
<span className={css.glyph} aria-hidden><StatusGlyph status={item.status} /></span>
|
||
<span className={css.content}>{item.content}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
|
||
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
|
||
|
||
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
|
||
export function TodoDock({ useProjection }: TodoDockProps) {
|
||
const todos = useProjection('todos')
|
||
return <TodoPanel todos={todos ?? []} />
|
||
}
|
||
|
||
/**
|
||
* The plan strip as a plain registrant plugin (QueueDock posture).
|
||
* `inject: ['conversation']` is the ordering seam: the conversation service
|
||
* mounts after ui-conversation's slot registrations, so the
|
||
* 'conversation.input.dock' declaration is on the ledger by then.
|
||
*/
|
||
export const todoDockEntry = {
|
||
name: 'conversation-todo-dock',
|
||
inject: ['slots', 'conversation'],
|
||
/**
|
||
* Register the plan strip between the goal and queue entries (order 10).
|
||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||
*/
|
||
apply(ctx: Context): void {
|
||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||
},
|
||
}
|