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

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.
@@ -1,55 +1,31 @@
1
1
  /**
2
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 模块)。
3
+ * Container 组件:负责数据获取、命令桥接、状态协调
4
+ * Presentational 组件在 TaskComponents.tsx
13
5
  */
14
6
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
15
7
  import {
16
8
  Button,
17
9
  IconChevronDownOutline14,
18
- IconChevronUpOutline14,
19
10
  IconCloseOutline16,
20
- IconRefreshOutline16,
21
- IconCheckOutline16,
22
- IconPlayOutline16,
23
11
  IconFolderOpenOutline16,
12
+ IconQuestionOutline14,
24
13
  } from '@deepseek-ai/dsh-client-ui-primitives'
25
14
  import { createTranslate, zh, en } from './locales.ts'
26
- import { taskStore, useTaskStore, parseTaskPayloadText, type TaskStep } from './task-store.ts'
15
+ import { useTaskData, useTaskDataActions, parseTaskPayloadText, type TaskStep } from './task-data-store.ts'
16
+ import { useTaskUI, useTaskUIActions } from './task-ui-store.ts'
17
+ import { MiniBar, TaskCard } from './TaskComponents.tsx'
27
18
  import css from './TaskPanel.module.css'
28
19
 
29
20
  const NS = 'dsh-project-memory'
30
21
 
22
+ const STATUS_CYCLE = ['pending', 'in_progress', 'completed'] as const
23
+
31
24
  function getT() {
32
25
  const locale = (typeof navigator !== 'undefined' && navigator.language.startsWith('zh')) ? zh : en
33
26
  return createTranslate(locale)
34
27
  }
35
28
 
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
29
  function useSessionId(ctx: any): string | null {
54
30
  const [, force] = useState(0)
55
31
  useEffect(() => {
@@ -64,139 +40,16 @@ function useSessionId(ctx: any): string | null {
64
40
  return first?.sessionId ?? null
65
41
  }
66
42
 
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
43
  class PanelErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
182
44
  state = { failed: false }
183
-
184
- static getDerivedStateFromError() {
185
- return { failed: true }
186
- }
187
-
45
+ static getDerivedStateFromError() { return { failed: true } }
188
46
  componentDidCatch(error: unknown) {
189
- console.warn('[dsh-project-memory] task panel render crashed (contained by boundary):', error)
47
+ console.warn('[dsh-project-memory] task panel render crashed:', error)
190
48
  }
191
-
192
49
  render() {
193
50
  if (!this.state.failed) return this.props.children
194
51
  return (
195
- <button
196
- className={css.boundaryFallback}
197
- onClick={() => this.setState({ failed: false })}
198
- title="重新渲染任务面板"
199
- >
52
+ <button className={css.boundaryFallback} onClick={() => this.setState({ failed: false })} title="重新渲染任务面板">
200
53
  任务面板(点击重试)
201
54
  </button>
202
55
  )
@@ -213,25 +66,24 @@ export function TaskPanelEntry({ ctx }: { ctx: any }) {
213
66
 
214
67
  function TaskPanelView({ ctx }: { ctx: any }) {
215
68
  const t = getT()
216
- const state = useTaskStore()
69
+ const data = useTaskData()
70
+ const ui = useTaskUI()
217
71
  const sessionId = useSessionId(ctx)
218
72
  const [syncing, setSyncing] = useState(false)
219
73
  const [syncedAt, setSyncedAt] = useState(0)
220
- const [copiedPath, setCopiedPath] = useState<string | null>(null)
221
74
  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)
75
+ const [showHints, setShowHints] = useState(true)
225
76
 
226
- const actions = taskStore.actions
77
+ const dataActions = useTaskDataActions()
78
+ const uiActions = useTaskUIActions()
227
79
 
228
- const activeTasks = state.tasks.filter((task) => !task.archived)
229
- const boundTask = state.boundTaskId ? state.tasks.find((task) => task.id === state.boundTaskId) ?? null : null
80
+ const activeTasks = data.tasks.filter((task) => !task.archived)
81
+ const boundTask = data.boundTaskId ? data.tasks.find((task) => task.id === data.boundTaskId) ?? null : null
230
82
 
231
83
  const applyPayload = (text?: string): boolean => {
232
84
  const parsed = parseTaskPayloadText(text)
233
85
  if (!parsed) return false
234
- actions.setTasks({ tasks: parsed.tasks, boundTaskId: parsed.boundId, archivedCount: parsed.archived })
86
+ dataActions.setTasks({ tasks: parsed.tasks, boundTaskId: parsed.boundId, archivedCount: parsed.archived })
235
87
  setSyncedAt(Date.now())
236
88
  setSyncError(null)
237
89
  return true
@@ -264,7 +116,12 @@ function TaskPanelView({ ctx }: { ctx: any }) {
264
116
  if (syncing) return
265
117
  setSyncing(true)
266
118
  try {
119
+ const prevBoundId = data.boundTaskId
267
120
  await runLine('/tasks')
121
+ const newBoundId = useTaskData.getSnapshot().boundTaskId
122
+ if (newBoundId && newBoundId !== prevBoundId) {
123
+ await runLine(`/task switch ${newBoundId}`)
124
+ }
268
125
  } finally {
269
126
  setSyncing(false)
270
127
  }
@@ -280,14 +137,12 @@ function TaskPanelView({ ctx }: { ctx: any }) {
280
137
  }
281
138
  }
282
139
 
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
140
  const pushSteps = (taskId: string, steps: TaskStep[]): void => {
287
141
  void runLine(`/task todos ${JSON.stringify(steps.map((s) => ({ content: s.content, status: s.status })))}`)
288
142
  }
143
+
289
144
  const cycleStatus = (taskId: string, index: number) => {
290
- const task = state.tasks.find((tt) => tt.id === taskId)
145
+ const task = data.tasks.find((tt) => tt.id === taskId)
291
146
  if (!task) return
292
147
  const steps = task.steps || []
293
148
  const next = steps.map((s, i) => {
@@ -297,45 +152,55 @@ function TaskPanelView({ ctx }: { ctx: any }) {
297
152
  })
298
153
  pushSteps(taskId, next)
299
154
  }
155
+
300
156
  const commitStepText = (taskId: string, index: number, value: string) => {
301
- const task = state.tasks.find((tt) => tt.id === taskId)
157
+ const task = data.tasks.find((tt) => tt.id === taskId)
302
158
  const trimmed = value.trim()
303
- if (!task) { setEditing(null); return }
159
+ if (!task) return
304
160
  const same = (task.steps || [])[index]?.content === trimmed
305
- setEditing(null)
306
161
  if (same || !trimmed) return
307
162
  const next = (task.steps || []).map((s, i) => (i === index ? { ...s, content: trimmed } : s))
308
163
  pushSteps(taskId, next)
309
164
  }
310
165
 
311
- // 绑定卡片标题双击改名
312
- const [editingTitle, setEditingTitle] = useState<{ taskId: string; value: string } | null>(null)
166
+ const reorderSteps = (taskId: string, fromIndex: number, toIndex: number) => {
167
+ if (fromIndex === toIndex) return
168
+ const task = data.tasks.find((tt) => tt.id === taskId)
169
+ if (!task) return
170
+ const steps = [...(task.steps || [])]
171
+ const [moved] = steps.splice(fromIndex, 1)
172
+ const targetIndex = toIndex > fromIndex ? toIndex - 1 : toIndex
173
+ steps.splice(targetIndex, 0, moved)
174
+ pushSteps(taskId, steps)
175
+ }
176
+
313
177
  const commitTitle = (taskId: string, value: string) => {
314
- const task = state.tasks.find((tt) => tt.id === taskId)
178
+ const task = data.tasks.find((tt) => tt.id === taskId)
315
179
  const trimmed = value.trim()
316
- if (!task) { setEditingTitle(null); return }
180
+ if (!task) return
317
181
  const same = task.title === trimmed
318
- setEditingTitle(null)
319
182
  if (same || !trimmed) return
320
183
  void runLine(`/task rename ${taskId} ${JSON.stringify(trimmed)}`)
321
184
  }
322
185
 
323
186
  const expand = () => {
324
- actions.open()
325
- // 已有 30s 内的快照就不重复执行 /tasks(避免会话里堆命令节点)
326
- if (Date.now() - Math.max(state.lastUpdate, syncedAt) > 30_000) {
187
+ uiActions.open()
188
+ if (Date.now() - Math.max(data.lastUpdate, syncedAt) > 30_000) {
327
189
  void refresh()
328
190
  }
329
191
  }
330
192
 
331
193
  const THEMES = ['native', 'glass', 'brutal', 'mono'] as const
332
- const style = (THEMES as readonly string[]).includes(state.theme) ? state.theme : 'native'
194
+ const style = (THEMES as readonly string[]).includes(ui.theme) ? ui.theme : 'native'
333
195
  const styleLabel = t(`style.${style}` as keyof typeof zh)
334
196
  const cycleTheme = () => {
335
197
  const i = THEMES.indexOf(style as (typeof THEMES)[number])
336
- actions.setTheme(THEMES[(i + 1) % THEMES.length])
198
+ uiActions.setTheme(THEMES[(i + 1) % THEMES.length])
337
199
  }
338
200
 
201
+ const panelRef = useRef<HTMLDivElement>(null)
202
+ const drag = useRef<{ dx: number; dy: number } | null>(null)
203
+
339
204
  const handleDragStart = (e: React.MouseEvent) => {
340
205
  if (e.target !== e.currentTarget) return
341
206
  const rect = panelRef.current?.getBoundingClientRect()
@@ -347,7 +212,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
347
212
  const height = panelRef.current?.offsetHeight ?? 480
348
213
  const x = Math.max(8, Math.min(window.innerWidth - width - 8, ev.clientX - drag.current.dx))
349
214
  const y = Math.max(8, Math.min(window.innerHeight - 64, ev.clientY - drag.current.dy))
350
- actions.setPanelPosition({ x, y })
215
+ uiActions.setPanelPosition({ x, y })
351
216
  }
352
217
  const onUp = () => {
353
218
  drag.current = null
@@ -358,35 +223,37 @@ function TaskPanelView({ ctx }: { ctx: any }) {
358
223
  window.addEventListener('mouseup', onUp)
359
224
  }
360
225
 
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 从未同步过)自动拉一次快照(无论是否折叠,折叠条也要显示计数)
226
+ // 会话切换时仅同步数据,不自动打开面板
372
227
  useEffect(() => {
373
228
  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)
229
+ // 仅当面板已打开时才自动刷新
230
+ if (!ui.closed) {
231
+ void refresh()
378
232
  }
379
- return undefined
380
- // eslint-disable-next-line react-hooks/exhaustive-deps
381
233
  }, [sessionId])
382
234
 
383
- // 彻底隐藏(关闭):不渲染任何东西;输入 /task、/tasks 会重新打开
384
- if (state.closed) {
235
+ // 监听模型工具请求打开面板
236
+ useEffect(() => {
237
+ const ctxWithEvents = ctx
238
+ const handler = () => {
239
+ uiActions.open()
240
+ if (Date.now() - Math.max(data.lastUpdate, syncedAt) > 30_000) {
241
+ void refresh()
242
+ }
243
+ }
244
+ ctxWithEvents?.events?.on?.('dsh:task-panel:show', handler)
245
+ return () => {
246
+ ctxWithEvents?.events?.off?.('dsh:task-panel:show', handler)
247
+ }
248
+ }, [ctx, uiActions, data.lastUpdate, syncedAt, refresh])
249
+
250
+ // 彻底隐藏:不渲染任何东西
251
+ if (ui.closed) {
385
252
  return null
386
253
  }
387
254
 
388
- // 折叠态:可拖拽的顶部迷你条(点击展开,点开即同步)
389
- if (state.minimized) {
255
+ // 折叠态:迷你条
256
+ if (ui.minimized) {
390
257
  const label = boundTask
391
258
  ? `${t('minibar.current')}: ${boundTask.title}`
392
259
  : activeTasks.length > 0
@@ -396,9 +263,9 @@ function TaskPanelView({ ctx }: { ctx: any }) {
396
263
  <MiniBar
397
264
  label={label}
398
265
  hint={t('minibar.click-expand')}
399
- position={state.panelPosition}
266
+ position={ui.panelPosition}
400
267
  theme={style}
401
- onMove={(pos) => actions.setPanelPosition(pos)}
268
+ onMove={uiActions.setPanelPosition}
402
269
  open={expand}
403
270
  />
404
271
  )
@@ -413,7 +280,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
413
280
  <div
414
281
  className={css.panel}
415
282
  ref={panelRef}
416
- style={{ left: state.panelPosition.x, top: state.panelPosition.y }}
283
+ style={{ left: ui.panelPosition.x, top: ui.panelPosition.y }}
417
284
  data-theme={style === 'native' ? undefined : style}
418
285
  >
419
286
  <div className={css.dragHandle} onMouseDown={handleDragStart} title={t('task.drag')}>
@@ -421,7 +288,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
421
288
  </div>
422
289
  <header className={css.header}>
423
290
  <div className={css.headerLeft}>
424
- <button
291
+ <Button
425
292
  type="button"
426
293
  className={css.headerIconBtn}
427
294
  onClick={cycleTheme}
@@ -429,7 +296,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
429
296
  aria-label={styleLabel}
430
297
  >
431
298
  <IconFolderOpenOutline16 className={css.headerIcon} />
432
- </button>
299
+ </Button>
433
300
  <h2 className={css.headerTitle}>{t('panel.title')}</h2>
434
301
  </div>
435
302
  <div className={css.headerRight}>
@@ -441,18 +308,16 @@ function TaskPanelView({ ctx }: { ctx: any }) {
441
308
  <Button
442
309
  variant="ghost"
443
310
  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')}
311
+ onClick={() => setShowHints(!showHints)}
312
+ aria-label={showHints ? t('panel.hints-off') : t('panel.hints-on')}
313
+ title={showHints ? t('panel.hints-off') : t('panel.hints-on')}
449
314
  >
450
- <IconRefreshOutline16 />
315
+ <IconQuestionOutline14 size={16} />
451
316
  </Button>
452
317
  <Button
453
318
  variant="ghost"
454
319
  size="sm"
455
- onClick={() => actions.minimize()}
320
+ onClick={() => uiActions.minimize()}
456
321
  aria-label={t('panel.minimize')}
457
322
  title={t('panel.minimize')}
458
323
  >
@@ -461,7 +326,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
461
326
  <Button
462
327
  variant="ghost"
463
328
  size="sm"
464
- onClick={() => actions.close()}
329
+ onClick={() => uiActions.close()}
465
330
  aria-label={t('panel.close')}
466
331
  title={t('panel.close')}
467
332
  >
@@ -483,168 +348,26 @@ function TaskPanelView({ ctx }: { ctx: any }) {
483
348
  <div className={css.taskList}>
484
349
  {activeTasks.map((task) => {
485
350
  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
351
+ const expanded = ui.expandedTaskIds.includes(task.id)
352
+ const isBound = data.boundTaskId === task.id
490
353
  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>
354
+ <TaskCard
355
+ key={task.id}
356
+ task={task}
357
+ isBound={isBound}
358
+ expanded={expanded}
359
+ onToggleExpand={() => uiActions.toggleTaskExpanded(task.id)}
360
+ onSwitch={() => handleAction('switch', task.id)}
361
+ onUnbind={() => runLine('/task unbind')}
362
+ onArchive={() => handleAction('archive', task.id)}
363
+ onRename={(value) => commitTitle(task.id, value)}
364
+ onEditStep={(index, value) => commitStepText(task.id, index, value)}
365
+ onCycleStatus={(index) => cycleStatus(task.id, index)}
366
+ onReorderSteps={(from, to) => reorderSteps(task.id, from, to)}
367
+ showHints={showHints}
368
+ syncing={syncing}
369
+ t={t}
370
+ />
648
371
  )
649
372
  })}
650
373
  {syncing && <div className={css.syncHint}>{t('panel.syncing')}</div>}
@@ -652,4 +375,4 @@ function TaskPanelView({ ctx }: { ctx: any }) {
652
375
  )}
653
376
  </div>
654
377
  )
655
- }
378
+ }