@yolk_vat-y/dsh-project-memory 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,655 @@
1
+ /**
2
+ * Task Panel — dsh web `shell.overlay` 浮动任务面板。
3
+ *
4
+ * 升级原 `/tasks` 文本输出为可交互卡片:
5
+ * - 默认收成右侧浮标(不影响用户操作/不挡对话);
6
+ * - 展开时通过宿主 `remote.commands.execute(sessionId, '/tasks')` 拉取最新快照
7
+ * (命令只读,走 /tasks 同一数据源 = tasks.json,因此与模型维护的 todo 双向一致);
8
+ * - 卡片内「切换 / 归档」执行 `/task switch|archive <id>`,服务端返回新快照 JSON,
9
+ * 面板即时刷新,无需再跑一次 /tasks;
10
+ * - 可拖拽(仅头部把手),位置持久化 localStorage。
11
+ *
12
+ * 依赖仅 react + @deepseek-ai/dsh-client-ui-primitives(宿主 seed 模块)。
13
+ */
14
+ import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
15
+ import {
16
+ Button,
17
+ IconChevronDownOutline14,
18
+ IconChevronUpOutline14,
19
+ IconCloseOutline16,
20
+ IconRefreshOutline16,
21
+ IconCheckOutline16,
22
+ IconPlayOutline16,
23
+ IconFolderOpenOutline16,
24
+ } from '@deepseek-ai/dsh-client-ui-primitives'
25
+ import { createTranslate, zh, en } from './locales.ts'
26
+ import { taskStore, useTaskStore, parseTaskPayloadText, type TaskStep } from './task-store.ts'
27
+ import css from './TaskPanel.module.css'
28
+
29
+ const NS = 'dsh-project-memory'
30
+
31
+ function getT() {
32
+ const locale = (typeof navigator !== 'undefined' && navigator.language.startsWith('zh')) ? zh : en
33
+ return createTranslate(locale)
34
+ }
35
+
36
+ function timeAgo(iso?: string) {
37
+ if (!iso) return ''
38
+ const diff = Date.now() - new Date(iso).getTime()
39
+ const m = Math.floor(diff / 60000)
40
+ if (m < 1) return '刚刚'
41
+ if (m < 60) return `${m}分钟前`
42
+ const h = Math.floor(m / 60)
43
+ if (h < 24) return `${h}小时前`
44
+ return `${Math.floor(h / 24)}天前`
45
+ }
46
+
47
+ function StepIcon({ status }: { status: TaskStep['status'] }) {
48
+ if (status === 'completed') return <IconCheckOutline16 className={css.stepDone} />
49
+ if (status === 'in_progress') return <IconPlayOutline16 className={css.stepRun} />
50
+ return <span className={css.stepPending} />
51
+ }
52
+
53
+ function useSessionId(ctx: any): string | null {
54
+ const [, force] = useState(0)
55
+ useEffect(() => {
56
+ const list = ctx?.sessions?.list
57
+ if (!list || typeof list.subscribe !== 'function') return
58
+ return list.subscribe(() => force((n) => n + 1))
59
+ }, [ctx])
60
+ const snap = ctx?.sessions?.list?.getSnapshot?.()
61
+ if (!snap) return null
62
+ if (snap.current) return snap.current
63
+ const first = Array.isArray(snap.items) ? snap.items.find((s: any) => !s.blank) ?? snap.items[0] : undefined
64
+ return first?.sessionId ?? null
65
+ }
66
+
67
+ /** 行内编辑框:自动按内容增高(最多 160px),Enter 提交、Shift+Enter 换行、Esc 取消、失焦提交。 */
68
+ function AutoEdit({ value, onCommit, onCancel, singleLine }: {
69
+ value: string
70
+ onCommit: (value: string) => void
71
+ onCancel: () => void
72
+ singleLine?: boolean
73
+ }) {
74
+ const [text, setText] = useState(value)
75
+ const ref = useRef<HTMLTextAreaElement>(null)
76
+ const settled = useRef(false)
77
+ const once = (fn: () => void) => {
78
+ if (settled.current) return
79
+ settled.current = true
80
+ fn()
81
+ }
82
+
83
+ const adjust = () => {
84
+ const el = ref.current
85
+ if (!el) return
86
+ el.style.height = 'auto'
87
+ el.style.height = `${Math.min(el.scrollHeight, 160)}px`
88
+ }
89
+
90
+ useEffect(() => {
91
+ adjust()
92
+ const el = ref.current
93
+ el?.focus()
94
+ el?.select()
95
+ }, [])
96
+
97
+ return (
98
+ <textarea
99
+ ref={ref}
100
+ className={css.stepInput}
101
+ rows={1}
102
+ value={text}
103
+ onChange={(e) => { setText(e.target.value); adjust() }}
104
+ onBlur={() => once(() => onCommit(text))}
105
+ onKeyDown={(e) => {
106
+ if (e.key === 'Escape') {
107
+ e.preventDefault()
108
+ once(() => onCancel())
109
+ } else if (e.key === 'Enter' && (singleLine || !e.shiftKey)) {
110
+ e.preventDefault()
111
+ once(() => onCommit(text))
112
+ }
113
+ }}
114
+ />
115
+ )
116
+ }
117
+
118
+ /** 折叠迷你条:可拖拽(按住拖动,轻点展开)。 */
119
+ function MiniBar({ label, hint, position, theme, onMove, open }: {
120
+ label: string
121
+ hint: string
122
+ position: { x: number; y: number }
123
+ theme: string
124
+ onMove: (pos: { x: number; y: number }) => void
125
+ open: () => void
126
+ }) {
127
+ const barRef = useRef<HTMLDivElement>(null)
128
+ const down = useRef<{ dx: number; dy: number; sx: number; sy: number; moved: boolean } | null>(null)
129
+
130
+ const onMouseDown = (e: React.MouseEvent) => {
131
+ if (e.button !== 0) return
132
+ const rect = barRef.current?.getBoundingClientRect()
133
+ if (!rect) return
134
+ down.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, sx: e.clientX, sy: e.clientY, moved: false }
135
+ const onMoveEv = (ev: MouseEvent) => {
136
+ const d = down.current
137
+ if (!d) return
138
+ if (!d.moved && Math.abs(ev.clientX - d.sx) + Math.abs(ev.clientY - d.sy) > 4) d.moved = true
139
+ if (d.moved) {
140
+ const w = barRef.current?.offsetWidth ?? 260
141
+ const h = barRef.current?.offsetHeight ?? 34
142
+ const x = Math.max(8, Math.min(window.innerWidth - w - 8, ev.clientX - d.dx))
143
+ const y = Math.max(8, Math.min(window.innerHeight - h - 8, ev.clientY - d.dy))
144
+ onMove({ x, y })
145
+ }
146
+ }
147
+ const onUpEv = () => {
148
+ const d = down.current
149
+ down.current = null
150
+ window.removeEventListener('mousemove', onMoveEv)
151
+ window.removeEventListener('mouseup', onUpEv)
152
+ if (d && !d.moved) open()
153
+ }
154
+ window.addEventListener('mousemove', onMoveEv)
155
+ window.addEventListener('mouseup', onUpEv)
156
+ }
157
+
158
+ return (
159
+ <div
160
+ ref={barRef}
161
+ className={css.miniBar}
162
+ style={{ left: position.x, top: position.y }}
163
+ data-theme={theme === 'native' ? undefined : theme}
164
+ onMouseDown={onMouseDown}
165
+ role="button"
166
+ title={hint}
167
+ aria-label={hint}
168
+ >
169
+ <IconFolderOpenOutline16 className={css.miniIcon} />
170
+ <span className={css.miniText}>{label}</span>
171
+ <IconChevronUpOutline14 className={css.miniChevron} />
172
+ </div>
173
+ )
174
+ }
175
+
176
+ /**
177
+ * 错误边界:任务面板渲染一旦抛错,不再让 shell.overlay 把整条条目退休
178
+ * (宿主对崩溃条目会永久移除直到重载页面)。捕获后显示一个纯文本兜底,
179
+ * 点击重新渲染。
180
+ */
181
+ class PanelErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
182
+ state = { failed: false }
183
+
184
+ static getDerivedStateFromError() {
185
+ return { failed: true }
186
+ }
187
+
188
+ componentDidCatch(error: unknown) {
189
+ console.warn('[dsh-project-memory] task panel render crashed (contained by boundary):', error)
190
+ }
191
+
192
+ render() {
193
+ if (!this.state.failed) return this.props.children
194
+ return (
195
+ <button
196
+ className={css.boundaryFallback}
197
+ onClick={() => this.setState({ failed: false })}
198
+ title="重新渲染任务面板"
199
+ >
200
+ 任务面板(点击重试)
201
+ </button>
202
+ )
203
+ }
204
+ }
205
+
206
+ export function TaskPanelEntry({ ctx }: { ctx: any }) {
207
+ return (
208
+ <PanelErrorBoundary>
209
+ <TaskPanelView ctx={ctx} />
210
+ </PanelErrorBoundary>
211
+ )
212
+ }
213
+
214
+ function TaskPanelView({ ctx }: { ctx: any }) {
215
+ const t = getT()
216
+ const state = useTaskStore()
217
+ const sessionId = useSessionId(ctx)
218
+ const [syncing, setSyncing] = useState(false)
219
+ const [syncedAt, setSyncedAt] = useState(0)
220
+ const [copiedPath, setCopiedPath] = useState<string | null>(null)
221
+ const [syncError, setSyncError] = useState<string | null>(null)
222
+ const panelRef = useRef<HTMLDivElement>(null)
223
+ const drag = useRef<{ dx: number; dy: number } | null>(null)
224
+ const copyTimer = useRef<number | undefined>(undefined)
225
+
226
+ const actions = taskStore.actions
227
+
228
+ const activeTasks = state.tasks.filter((task) => !task.archived)
229
+ const boundTask = state.boundTaskId ? state.tasks.find((task) => task.id === state.boundTaskId) ?? null : null
230
+
231
+ const applyPayload = (text?: string): boolean => {
232
+ const parsed = parseTaskPayloadText(text)
233
+ if (!parsed) return false
234
+ actions.setTasks({ tasks: parsed.tasks, boundTaskId: parsed.boundId, archivedCount: parsed.archived })
235
+ setSyncedAt(Date.now())
236
+ setSyncError(null)
237
+ return true
238
+ }
239
+
240
+ const runLine = async (line: string): Promise<boolean> => {
241
+ const commands = ctx?.remote?.commands
242
+ if (!sessionId || !commands || typeof commands.execute !== 'function') {
243
+ setSyncError('no session / commands service')
244
+ return false
245
+ }
246
+ let response: any
247
+ try {
248
+ response = await commands.execute(sessionId, line, [])
249
+ } catch (err) {
250
+ setSyncError(String(err?.message ?? err))
251
+ return false
252
+ }
253
+ const envelope = response as { ok?: boolean; value?: any } | null | undefined
254
+ const execution = envelope && typeof envelope === 'object' && 'value' in envelope ? envelope.value : envelope
255
+ const result = execution?.result ?? execution
256
+ if (result?.kind === 'error') {
257
+ setSyncError(result.text ?? 'command error')
258
+ return false
259
+ }
260
+ return applyPayload(result?.text)
261
+ }
262
+
263
+ const refresh = async (): Promise<void> => {
264
+ if (syncing) return
265
+ setSyncing(true)
266
+ try {
267
+ await runLine('/tasks')
268
+ } finally {
269
+ setSyncing(false)
270
+ }
271
+ }
272
+
273
+ const handleAction = async (verb: 'switch' | 'archive', taskId: string): Promise<void> => {
274
+ if (syncing) return
275
+ setSyncing(true)
276
+ try {
277
+ await runLine(`/task ${verb} ${taskId}`)
278
+ } finally {
279
+ setSyncing(false)
280
+ }
281
+ }
282
+
283
+ // —— 绑定卡片步骤编辑(双击改文案 / 点状态图标循环),与模型 todo_write 走同一数据 ——
284
+ const [editing, setEditing] = useState<{ taskId: string; index: number; value: string } | null>(null)
285
+ const STATUS_CYCLE = ['pending', 'in_progress', 'completed'] as const
286
+ const pushSteps = (taskId: string, steps: TaskStep[]): void => {
287
+ void runLine(`/task todos ${JSON.stringify(steps.map((s) => ({ content: s.content, status: s.status })))}`)
288
+ }
289
+ const cycleStatus = (taskId: string, index: number) => {
290
+ const task = state.tasks.find((tt) => tt.id === taskId)
291
+ if (!task) return
292
+ const steps = task.steps || []
293
+ const next = steps.map((s, i) => {
294
+ if (i !== index) return s
295
+ const cur = STATUS_CYCLE.indexOf(s.status)
296
+ return { ...s, status: STATUS_CYCLE[(cur + 1) % STATUS_CYCLE.length] }
297
+ })
298
+ pushSteps(taskId, next)
299
+ }
300
+ const commitStepText = (taskId: string, index: number, value: string) => {
301
+ const task = state.tasks.find((tt) => tt.id === taskId)
302
+ const trimmed = value.trim()
303
+ if (!task) { setEditing(null); return }
304
+ const same = (task.steps || [])[index]?.content === trimmed
305
+ setEditing(null)
306
+ if (same || !trimmed) return
307
+ const next = (task.steps || []).map((s, i) => (i === index ? { ...s, content: trimmed } : s))
308
+ pushSteps(taskId, next)
309
+ }
310
+
311
+ // 绑定卡片标题双击改名
312
+ const [editingTitle, setEditingTitle] = useState<{ taskId: string; value: string } | null>(null)
313
+ const commitTitle = (taskId: string, value: string) => {
314
+ const task = state.tasks.find((tt) => tt.id === taskId)
315
+ const trimmed = value.trim()
316
+ if (!task) { setEditingTitle(null); return }
317
+ const same = task.title === trimmed
318
+ setEditingTitle(null)
319
+ if (same || !trimmed) return
320
+ void runLine(`/task rename ${taskId} ${JSON.stringify(trimmed)}`)
321
+ }
322
+
323
+ const expand = () => {
324
+ actions.open()
325
+ // 已有 30s 内的快照就不重复执行 /tasks(避免会话里堆命令节点)
326
+ if (Date.now() - Math.max(state.lastUpdate, syncedAt) > 30_000) {
327
+ void refresh()
328
+ }
329
+ }
330
+
331
+ const THEMES = ['native', 'glass', 'brutal', 'mono'] as const
332
+ const style = (THEMES as readonly string[]).includes(state.theme) ? state.theme : 'native'
333
+ const styleLabel = t(`style.${style}` as keyof typeof zh)
334
+ const cycleTheme = () => {
335
+ const i = THEMES.indexOf(style as (typeof THEMES)[number])
336
+ actions.setTheme(THEMES[(i + 1) % THEMES.length])
337
+ }
338
+
339
+ const handleDragStart = (e: React.MouseEvent) => {
340
+ if (e.target !== e.currentTarget) return
341
+ const rect = panelRef.current?.getBoundingClientRect()
342
+ if (!rect) return
343
+ drag.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }
344
+ const onMove = (ev: MouseEvent) => {
345
+ if (!drag.current) return
346
+ const width = panelRef.current?.offsetWidth ?? 380
347
+ const height = panelRef.current?.offsetHeight ?? 480
348
+ const x = Math.max(8, Math.min(window.innerWidth - width - 8, ev.clientX - drag.current.dx))
349
+ const y = Math.max(8, Math.min(window.innerHeight - 64, ev.clientY - drag.current.dy))
350
+ actions.setPanelPosition({ x, y })
351
+ }
352
+ const onUp = () => {
353
+ drag.current = null
354
+ window.removeEventListener('mousemove', onMove)
355
+ window.removeEventListener('mouseup', onUp)
356
+ }
357
+ window.addEventListener('mousemove', onMove)
358
+ window.addEventListener('mouseup', onUp)
359
+ }
360
+
361
+ const copyPath = (path: string) => {
362
+ if (copiedPath === path) return
363
+ void navigator.clipboard?.writeText(path).catch(() => undefined)
364
+ setCopiedPath(path)
365
+ window.clearTimeout(copyTimer.current)
366
+ copyTimer.current = window.setTimeout(() => setCopiedPath(null), 1200)
367
+ }
368
+
369
+ useEffect(() => () => window.clearTimeout(copyTimer.current), [])
370
+
371
+ // 首次(store 从未同步过)自动拉一次快照(无论是否折叠,折叠条也要显示计数)
372
+ useEffect(() => {
373
+ if (!sessionId) return
374
+ const snap = taskStore.getSnapshot()
375
+ if (snap.lastUpdate === 0 && !snap.closed) {
376
+ const timer = window.setTimeout(() => void refresh(), 300)
377
+ return () => window.clearTimeout(timer)
378
+ }
379
+ return undefined
380
+ // eslint-disable-next-line react-hooks/exhaustive-deps
381
+ }, [sessionId])
382
+
383
+ // 彻底隐藏(关闭):不渲染任何东西;输入 /task、/tasks 会重新打开
384
+ if (state.closed) {
385
+ return null
386
+ }
387
+
388
+ // 折叠态:可拖拽的顶部迷你条(点击展开,点开即同步)
389
+ if (state.minimized) {
390
+ const label = boundTask
391
+ ? `${t('minibar.current')}: ${boundTask.title}`
392
+ : activeTasks.length > 0
393
+ ? `${t('minibar.current')}: ${activeTasks.length} ${t('minibar.tasks')}`
394
+ : t('minibar.no-task')
395
+ return (
396
+ <MiniBar
397
+ label={label}
398
+ hint={t('minibar.click-expand')}
399
+ position={state.panelPosition}
400
+ theme={style}
401
+ onMove={(pos) => actions.setPanelPosition(pos)}
402
+ open={expand}
403
+ />
404
+ )
405
+ }
406
+
407
+ const doneCount = activeTasks.filter((task) => {
408
+ const steps = task.steps || []
409
+ return steps.length > 0 && steps.every((s) => s.status === 'completed')
410
+ }).length
411
+
412
+ return (
413
+ <div
414
+ className={css.panel}
415
+ ref={panelRef}
416
+ style={{ left: state.panelPosition.x, top: state.panelPosition.y }}
417
+ data-theme={style === 'native' ? undefined : style}
418
+ >
419
+ <div className={css.dragHandle} onMouseDown={handleDragStart} title={t('task.drag')}>
420
+ <div className={css.handleGrip} />
421
+ </div>
422
+ <header className={css.header}>
423
+ <div className={css.headerLeft}>
424
+ <button
425
+ type="button"
426
+ className={css.headerIconBtn}
427
+ onClick={cycleTheme}
428
+ title={styleLabel}
429
+ aria-label={styleLabel}
430
+ >
431
+ <IconFolderOpenOutline16 className={css.headerIcon} />
432
+ </button>
433
+ <h2 className={css.headerTitle}>{t('panel.title')}</h2>
434
+ </div>
435
+ <div className={css.headerRight}>
436
+ <span className={css.counts}>
437
+ {activeTasks.length > 0
438
+ ? `${t('panel.active')} ${activeTasks.length} · ${doneCount}/${activeTasks.length}`
439
+ : ''}
440
+ </span>
441
+ <Button
442
+ variant="ghost"
443
+ size="sm"
444
+ className={syncing ? css.spinning : undefined}
445
+ onClick={() => void refresh()}
446
+ disabled={syncing}
447
+ aria-label={t('panel.refresh')}
448
+ title={t('panel.refresh')}
449
+ >
450
+ <IconRefreshOutline16 />
451
+ </Button>
452
+ <Button
453
+ variant="ghost"
454
+ size="sm"
455
+ onClick={() => actions.minimize()}
456
+ aria-label={t('panel.minimize')}
457
+ title={t('panel.minimize')}
458
+ >
459
+ <IconChevronDownOutline14 />
460
+ </Button>
461
+ <Button
462
+ variant="ghost"
463
+ size="sm"
464
+ onClick={() => actions.close()}
465
+ aria-label={t('panel.close')}
466
+ title={t('panel.close')}
467
+ >
468
+ <IconCloseOutline16 />
469
+ </Button>
470
+ </div>
471
+ </header>
472
+
473
+ {!sessionId && <div className={css.notice}>{t('panel.no-session')}</div>}
474
+ {syncError && <div className={css.notice}>{t('panel.sync-failed')}: {syncError}</div>}
475
+
476
+ {activeTasks.length === 0 ? (
477
+ <div className={css.emptyState}>
478
+ <p className={css.emptyTitle}>{t('panel.empty')}</p>
479
+ <p className={css.emptyDesc}>{t('panel.empty-desc')}</p>
480
+ {syncing && <p className={css.syncHint}>{t('panel.syncing')}</p>}
481
+ </div>
482
+ ) : (
483
+ <div className={css.taskList}>
484
+ {activeTasks.map((task) => {
485
+ const steps = task.steps || []
486
+ const done = steps.filter((s) => s.status === 'completed').length
487
+ const expanded = state.expandedTaskIds.includes(task.id)
488
+ const isBound = state.boundTaskId === task.id
489
+ const pct = steps.length > 0 ? Math.round((done / steps.length) * 100) : 0
490
+ return (
491
+ <article key={task.id} className={`${css.card}${isBound ? ` ${css.cardBound}` : ''}`}>
492
+ <button
493
+ className={css.cardHead}
494
+ onClick={() => { if (editingTitle?.taskId === task.id) return; actions.toggleTaskExpanded(task.id) }}
495
+ aria-expanded={expanded}
496
+ >
497
+ <div className={css.cardTitleRow}>
498
+ {isBound && <span className={css.boundBadge}>{t('task.current')}</span>}
499
+ {isBound && editingTitle?.taskId === task.id ? (
500
+ <AutoEdit
501
+ value={editingTitle.value}
502
+ singleLine
503
+ onCommit={(v) => commitTitle(task.id, v)}
504
+ onCancel={() => setEditingTitle(null)}
505
+ />
506
+ ) : (
507
+ <span
508
+ className={`${css.cardTitle}${isBound ? ` ${css.cardTitleEditable}` : ''}`}
509
+ title={isBound ? t('task.title-edit') : undefined}
510
+ onDoubleClick={(e) => {
511
+ e.preventDefault()
512
+ e.stopPropagation()
513
+ if (isBound) setEditingTitle({ taskId: task.id, value: task.title })
514
+ }}
515
+ >
516
+ {task.title}
517
+ </span>
518
+ )}
519
+ </div>
520
+ <div className={css.cardMeta}>
521
+ {steps.length > 0 && (
522
+ <span className={css.progressText}>
523
+ {done}/{steps.length}
524
+ </span>
525
+ )}
526
+ <span className={css.updated}>{timeAgo(task.updatedAt || task.lastActiveAt)}</span>
527
+ <span className={css.chevron}>
528
+ {expanded ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
529
+ </span>
530
+ </div>
531
+ </button>
532
+ {steps.length > 0 && (
533
+ <div className={css.track}>
534
+ <div className={css.trackFill} style={{ width: `${pct}%` }} />
535
+ </div>
536
+ )}
537
+ {expanded && (
538
+ <div className={css.cardBody}>
539
+ <div className={css.section}>
540
+ <div className={css.sectionLabel}>
541
+ {t('task.steps')}
542
+ {steps.length > 0 && <span className={css.sectionCount}>{steps.length}</span>}
543
+ </div>
544
+ {steps.length > 0 ? (
545
+ <ol className={css.stepsList}>
546
+ {steps.map((step, i) => {
547
+ const isEditing = isBound && editing?.taskId === task.id && editing.index === i
548
+ return (
549
+ <li key={i} className={css.stepRow}>
550
+ {isBound ? (
551
+ <button
552
+ type="button"
553
+ className={css.stepToggle}
554
+ disabled={syncing}
555
+ onClick={() => cycleStatus(task.id, i)}
556
+ title={`${t('step.completed')}/${t('step.in-progress')}/${t('step.pending')}(点击切换)`}
557
+ >
558
+ <StepIcon status={step.status} />
559
+ </button>
560
+ ) : (
561
+ <StepIcon status={step.status} />
562
+ )}
563
+ {isEditing ? (
564
+ <AutoEdit
565
+ value={editing!.value}
566
+ onCommit={(v) => commitStepText(task.id, i, v)}
567
+ onCancel={() => setEditing(null)}
568
+ />
569
+ ) : (
570
+ <span
571
+ className={`${css.stepContent}${step.status === 'completed' ? ` ${css.stepContentDone}` : ''}${step.status === 'in_progress' ? ` ${css.stepContentRun}` : ''}${isBound ? ` ${css.stepEditable}` : ''}`}
572
+ title={isBound ? t('step.edit-hint') : undefined}
573
+ onDoubleClick={() => {
574
+ if (isBound) setEditing({ taskId: task.id, index: i, value: String(step.content ?? '') })
575
+ }}
576
+ >
577
+ {step.content}
578
+ </span>
579
+ )}
580
+ </li>
581
+ )
582
+ })}
583
+ </ol>
584
+ ) : (
585
+ <div className={css.muted}>{t('panel.empty-desc')}</div>
586
+ )}
587
+ </div>
588
+
589
+ {(task.files?.length ?? 0) > 0 && (
590
+ <div className={css.section}>
591
+ <div className={css.sectionLabel}>
592
+ {t('task.files')}
593
+ <span className={css.sectionCount}>{task.files.length}</span>
594
+ </div>
595
+ <ul className={css.filesList}>
596
+ {task.files.slice(0, 12).map((file, i) => (
597
+ <li key={i}>
598
+ <button
599
+ className={css.fileRow}
600
+ onClick={() => copyPath(file.path)}
601
+ title={`${t(copiedPath === file.path ? 'file.copied' : 'file.copy')}: ${file.path}${file.line ? `:${file.line}` : ''}`}
602
+ >
603
+ <span className={css.fileDot} />
604
+ <span className={css.filePath}>{file.path}</span>
605
+ {file.line && <span className={css.fileLine}>:{file.line}</span>}
606
+ </button>
607
+ </li>
608
+ ))}
609
+ {(task.files?.length ?? 0) > 12 && (
610
+ <li className={css.muted}>… 共 {task.files.length} 个</li>
611
+ )}
612
+ </ul>
613
+ </div>
614
+ )}
615
+
616
+ <div className={css.cardFooter}>
617
+ <Button
618
+ variant="outline"
619
+ size="sm"
620
+ disabled={isBound || syncing}
621
+ onClick={() => void handleAction('switch', task.id)}
622
+ >
623
+ {t('task.switch')}
624
+ </Button>
625
+ {isBound && (
626
+ <Button
627
+ variant="outline"
628
+ size="sm"
629
+ disabled={syncing}
630
+ onClick={() => void runLine('/task unbind')}
631
+ title={t('task.unbind')}
632
+ >
633
+ {t('task.unbind')}
634
+ </Button>
635
+ )}
636
+ <Button
637
+ variant="outline"
638
+ size="sm"
639
+ disabled={syncing}
640
+ onClick={() => void handleAction('archive', task.id)}
641
+ >
642
+ {t('task.archive')}
643
+ </Button>
644
+ </div>
645
+ </div>
646
+ )}
647
+ </article>
648
+ )
649
+ })}
650
+ {syncing && <div className={css.syncHint}>{t('panel.syncing')}</div>}
651
+ </div>
652
+ )}
653
+ </div>
654
+ )
655
+ }