|
|
@@ -3,8 +3,9 @@
|
|
|
// 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 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
|
|
|
|
|
|
-import { useState } from 'react'
|
|
|
+import { useId, useState } from 'react'
|
|
|
import type { Context } from 'cordis'
|
|
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
@@ -16,45 +17,97 @@ export interface TodoPanelProps {
|
|
|
todos: readonly TodoItem[]
|
|
|
}
|
|
|
|
|
|
-/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */
|
|
|
-const STATUS_GLYPHS: Record<TodoItem['status'], string> = {
|
|
|
- completed: '✓', in_progress: '●', pending: '○',
|
|
|
+/** 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(false)
|
|
|
if (todos.length === 0) return null
|
|
|
|
|
|
- const done = todos.filter(t => t.status === 'completed').length
|
|
|
- const active = todos.find(t => t.status === 'in_progress')
|
|
|
-
|
|
|
return (
|
|
|
- <section className={css.root} data-testid="todo-panel" aria-label="任务清单">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.header}
|
|
|
- aria-expanded={!collapsed}
|
|
|
- onClick={() => { setCollapsed(v => !v) }}
|
|
|
- >
|
|
|
- <span className={css.title}>Plan</span>
|
|
|
- <span className={css.progress}>{done}/{todos.length}</span>
|
|
|
- {collapsed && active !== undefined && (
|
|
|
- <span className={css.activeHint}>{active.content}</span>
|
|
|
+ <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>
|
|
|
)}
|
|
|
- <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>{STATUS_GLYPHS[item.status]}</span>
|
|
|
- <span className={css.content}>{item.content}</span>
|
|
|
- </li>
|
|
|
- ))}
|
|
|
- </ul>
|
|
|
- )}
|
|
|
+ </div>
|
|
|
</section>
|
|
|
)
|
|
|
}
|