|
|
@@ -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,89 @@ 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: '○',
|
|
|
+/** Completed: green check ring (figma ic_ds_check_16). */
|
|
|
+function CompletedGlyph() {
|
|
|
+ return (
|
|
|
+ <svg width={16} height={16} 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={16} height={16} viewBox="0 0 16 16" fill="none" aria-hidden="true" className={css.glyphProgress}>
|
|
|
+ <defs>
|
|
|
+ <linearGradient id={gradientId} x1="3.5" y1="13" x2="11.5" y2="4.5" gradientUnits="userSpaceOnUse">
|
|
|
+ <stop stopColor="currentColor" />
|
|
|
+ <stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
|
|
+ </linearGradient>
|
|
|
+ </defs>
|
|
|
+ <circle cx="8" cy="8" r="6.4" stroke={`url(#${gradientId})`} strokeWidth="1.2" />
|
|
|
+ </svg>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/** Pending: dashed unstarted ring (figma 14px, 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 />
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 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>
|
|
|
)
|
|
|
}
|