@yolk_vat-y/dsh-project-memory 0.4.3 → 0.5.0

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,53 +1,32 @@
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, taskDataStore, parseTaskPayloadText, type TaskStep } from './task-data-store.ts'
16
+ import { useTaskUI, useTaskUIActions } from './task-ui-store.ts'
17
+ import { MiniBar, TaskCard } from './TaskComponents.tsx'
18
+ import { MemoryView, type InsightScope } from './MemoryView.tsx'
27
19
  import css from './TaskPanel.module.css'
28
20
 
29
21
  const NS = 'dsh-project-memory'
30
22
 
31
- function getT() {
32
- const locale = (typeof navigator !== 'undefined' && navigator.language.startsWith('zh')) ? zh : en
33
- return createTranslate(locale)
34
- }
23
+ const STATUS_CYCLE = ['pending', 'in_progress', 'completed'] as const
24
+ const VIEW_CYCLE = ['task', 'project', 'global'] as const
25
+ type PanelView = (typeof VIEW_CYCLE)[number]
35
26
 
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} />
27
+ function getT(ctx: any) {
28
+ const locale = ctx?.locale?.getSnapshot?.()?.active === 'zh' ? zh : en
29
+ return createTranslate(locale)
51
30
  }
52
31
 
53
32
  function useSessionId(ctx: any): string | null {
@@ -64,139 +43,16 @@ function useSessionId(ctx: any): string | null {
64
43
  return first?.sessionId ?? null
65
44
  }
66
45
 
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
46
  class PanelErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
182
47
  state = { failed: false }
183
-
184
- static getDerivedStateFromError() {
185
- return { failed: true }
186
- }
187
-
48
+ static getDerivedStateFromError() { return { failed: true } }
188
49
  componentDidCatch(error: unknown) {
189
- console.warn('[dsh-project-memory] task panel render crashed (contained by boundary):', error)
50
+ console.warn('[dsh-project-memory] task panel render crashed:', error)
190
51
  }
191
-
192
52
  render() {
193
53
  if (!this.state.failed) return this.props.children
194
54
  return (
195
- <button
196
- className={css.boundaryFallback}
197
- onClick={() => this.setState({ failed: false })}
198
- title="重新渲染任务面板"
199
- >
55
+ <button className={css.boundaryFallback} onClick={() => this.setState({ failed: false })} title="重新渲染任务面板">
200
56
  任务面板(点击重试)
201
57
  </button>
202
58
  )
@@ -212,26 +68,35 @@ export function TaskPanelEntry({ ctx }: { ctx: any }) {
212
68
  }
213
69
 
214
70
  function TaskPanelView({ ctx }: { ctx: any }) {
215
- const t = getT()
216
- const state = useTaskStore()
71
+ const t = getT(ctx)
72
+ const data = useTaskData()
73
+ const ui = useTaskUI()
217
74
  const sessionId = useSessionId(ctx)
218
75
  const [syncing, setSyncing] = useState(false)
219
76
  const [syncedAt, setSyncedAt] = useState(0)
220
- const [copiedPath, setCopiedPath] = useState<string | null>(null)
221
77
  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)
78
+ const [showHints, setShowHints] = useState(true)
79
+ const [view, setView] = useState<PanelView>('task')
80
+ const cycleView = () => {
81
+ const i = VIEW_CYCLE.indexOf(view)
82
+ setView(VIEW_CYCLE[(i + 1) % VIEW_CYCLE.length])
83
+ }
84
+ const viewTitle =
85
+ view === 'task' ? t('panel.title')
86
+ : view === 'global' ? t('view.global')
87
+ : t('view.project')
225
88
 
226
- const actions = taskStore.actions
89
+ const dataActions = useTaskDataActions()
90
+ const uiActions = useTaskUIActions()
227
91
 
228
- const activeTasks = state.tasks.filter((task) => !task.archived)
229
- const boundTask = state.boundTaskId ? state.tasks.find((task) => task.id === state.boundTaskId) ?? null : null
92
+ const tasks = Array.isArray(data.tasks) ? data.tasks : []
93
+ const activeTasks = tasks.filter((task) => !task.archived)
94
+ const boundTask = data.boundTaskId ? tasks.find((task) => task.id === data.boundTaskId) ?? null : null
230
95
 
231
96
  const applyPayload = (text?: string): boolean => {
232
97
  const parsed = parseTaskPayloadText(text)
233
98
  if (!parsed) return false
234
- actions.setTasks({ tasks: parsed.tasks, boundTaskId: parsed.boundId, archivedCount: parsed.archived })
99
+ dataActions.setTasks({ tasks: parsed.tasks, boundTaskId: parsed.boundId, archivedCount: parsed.archived })
235
100
  setSyncedAt(Date.now())
236
101
  setSyncError(null)
237
102
  return true
@@ -264,7 +129,12 @@ function TaskPanelView({ ctx }: { ctx: any }) {
264
129
  if (syncing) return
265
130
  setSyncing(true)
266
131
  try {
132
+ const prevBoundId = data.boundTaskId
267
133
  await runLine('/tasks')
134
+ const newBoundId = taskDataStore.getSnapshot().boundTaskId
135
+ if (newBoundId && newBoundId !== prevBoundId) {
136
+ await runLine(`/task switch ${newBoundId}`)
137
+ }
268
138
  } finally {
269
139
  setSyncing(false)
270
140
  }
@@ -280,14 +150,24 @@ function TaskPanelView({ ctx }: { ctx: any }) {
280
150
  }
281
151
  }
282
152
 
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
153
+ // v0.5:任务卡内联记忆动作(confirm/promote/delete 于 /insight task 层),随后刷新任务数据
154
+ const handleInsightTask = async (action: 'confirm' | 'promote' | 'delete', id: string): Promise<void> => {
155
+ if (syncing) return
156
+ setSyncing(true)
157
+ try {
158
+ await runLine(`/insight ${action} task ${id}`)
159
+ await refresh()
160
+ } finally {
161
+ setSyncing(false)
162
+ }
163
+ }
164
+
286
165
  const pushSteps = (taskId: string, steps: TaskStep[]): void => {
287
166
  void runLine(`/task todos ${JSON.stringify(steps.map((s) => ({ content: s.content, status: s.status })))}`)
288
167
  }
168
+
289
169
  const cycleStatus = (taskId: string, index: number) => {
290
- const task = state.tasks.find((tt) => tt.id === taskId)
170
+ const task = data.tasks.find((tt) => tt.id === taskId)
291
171
  if (!task) return
292
172
  const steps = task.steps || []
293
173
  const next = steps.map((s, i) => {
@@ -297,45 +177,55 @@ function TaskPanelView({ ctx }: { ctx: any }) {
297
177
  })
298
178
  pushSteps(taskId, next)
299
179
  }
180
+
300
181
  const commitStepText = (taskId: string, index: number, value: string) => {
301
- const task = state.tasks.find((tt) => tt.id === taskId)
182
+ const task = data.tasks.find((tt) => tt.id === taskId)
302
183
  const trimmed = value.trim()
303
- if (!task) { setEditing(null); return }
184
+ if (!task) return
304
185
  const same = (task.steps || [])[index]?.content === trimmed
305
- setEditing(null)
306
186
  if (same || !trimmed) return
307
187
  const next = (task.steps || []).map((s, i) => (i === index ? { ...s, content: trimmed } : s))
308
188
  pushSteps(taskId, next)
309
189
  }
310
190
 
311
- // 绑定卡片标题双击改名
312
- const [editingTitle, setEditingTitle] = useState<{ taskId: string; value: string } | null>(null)
191
+ const reorderSteps = (taskId: string, fromIndex: number, toIndex: number) => {
192
+ if (fromIndex === toIndex) return
193
+ const task = data.tasks.find((tt) => tt.id === taskId)
194
+ if (!task) return
195
+ const steps = [...(task.steps || [])]
196
+ const [moved] = steps.splice(fromIndex, 1)
197
+ const targetIndex = toIndex > fromIndex ? toIndex - 1 : toIndex
198
+ steps.splice(targetIndex, 0, moved)
199
+ pushSteps(taskId, steps)
200
+ }
201
+
313
202
  const commitTitle = (taskId: string, value: string) => {
314
- const task = state.tasks.find((tt) => tt.id === taskId)
203
+ const task = data.tasks.find((tt) => tt.id === taskId)
315
204
  const trimmed = value.trim()
316
- if (!task) { setEditingTitle(null); return }
205
+ if (!task) return
317
206
  const same = task.title === trimmed
318
- setEditingTitle(null)
319
207
  if (same || !trimmed) return
320
208
  void runLine(`/task rename ${taskId} ${JSON.stringify(trimmed)}`)
321
209
  }
322
210
 
323
211
  const expand = () => {
324
- actions.open()
325
- // 已有 30s 内的快照就不重复执行 /tasks(避免会话里堆命令节点)
326
- if (Date.now() - Math.max(state.lastUpdate, syncedAt) > 30_000) {
212
+ uiActions.open()
213
+ if (Date.now() - Math.max(data.lastUpdate, syncedAt) > 30_000) {
327
214
  void refresh()
328
215
  }
329
216
  }
330
217
 
331
218
  const THEMES = ['native', 'glass', 'brutal', 'mono'] as const
332
- const style = (THEMES as readonly string[]).includes(state.theme) ? state.theme : 'native'
219
+ const style = (THEMES as readonly string[]).includes(ui.theme) ? ui.theme : 'native'
333
220
  const styleLabel = t(`style.${style}` as keyof typeof zh)
334
221
  const cycleTheme = () => {
335
222
  const i = THEMES.indexOf(style as (typeof THEMES)[number])
336
- actions.setTheme(THEMES[(i + 1) % THEMES.length])
223
+ uiActions.setTheme(THEMES[(i + 1) % THEMES.length])
337
224
  }
338
225
 
226
+ const panelRef = useRef<HTMLDivElement>(null)
227
+ const drag = useRef<{ dx: number; dy: number } | null>(null)
228
+
339
229
  const handleDragStart = (e: React.MouseEvent) => {
340
230
  if (e.target !== e.currentTarget) return
341
231
  const rect = panelRef.current?.getBoundingClientRect()
@@ -347,7 +237,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
347
237
  const height = panelRef.current?.offsetHeight ?? 480
348
238
  const x = Math.max(8, Math.min(window.innerWidth - width - 8, ev.clientX - drag.current.dx))
349
239
  const y = Math.max(8, Math.min(window.innerHeight - 64, ev.clientY - drag.current.dy))
350
- actions.setPanelPosition({ x, y })
240
+ uiActions.setPanelPosition({ x, y })
351
241
  }
352
242
  const onUp = () => {
353
243
  drag.current = null
@@ -358,35 +248,37 @@ function TaskPanelView({ ctx }: { ctx: any }) {
358
248
  window.addEventListener('mouseup', onUp)
359
249
  }
360
250
 
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 从未同步过)自动拉一次快照(无论是否折叠,折叠条也要显示计数)
251
+ // 会话切换时仅同步数据,不自动打开面板
372
252
  useEffect(() => {
373
253
  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)
254
+ // 仅当面板已打开时才自动刷新
255
+ if (!ui.closed) {
256
+ void refresh()
378
257
  }
379
- return undefined
380
- // eslint-disable-next-line react-hooks/exhaustive-deps
381
258
  }, [sessionId])
382
259
 
383
- // 彻底隐藏(关闭):不渲染任何东西;输入 /task、/tasks 会重新打开
384
- if (state.closed) {
260
+ // 监听模型工具请求打开面板
261
+ useEffect(() => {
262
+ const ctxWithEvents = ctx
263
+ const handler = () => {
264
+ uiActions.open()
265
+ if (Date.now() - Math.max(data.lastUpdate, syncedAt) > 30_000) {
266
+ void refresh()
267
+ }
268
+ }
269
+ ctxWithEvents?.events?.on?.('dsh:task-panel:show', handler)
270
+ return () => {
271
+ ctxWithEvents?.events?.off?.('dsh:task-panel:show', handler)
272
+ }
273
+ }, [ctx, uiActions, data.lastUpdate, syncedAt, refresh])
274
+
275
+ // 彻底隐藏:不渲染任何东西
276
+ if (ui.closed) {
385
277
  return null
386
278
  }
387
279
 
388
- // 折叠态:可拖拽的顶部迷你条(点击展开,点开即同步)
389
- if (state.minimized) {
280
+ // 折叠态:迷你条
281
+ if (ui.minimized) {
390
282
  const label = boundTask
391
283
  ? `${t('minibar.current')}: ${boundTask.title}`
392
284
  : activeTasks.length > 0
@@ -396,9 +288,9 @@ function TaskPanelView({ ctx }: { ctx: any }) {
396
288
  <MiniBar
397
289
  label={label}
398
290
  hint={t('minibar.click-expand')}
399
- position={state.panelPosition}
291
+ position={ui.panelPosition}
400
292
  theme={style}
401
- onMove={(pos) => actions.setPanelPosition(pos)}
293
+ onMove={uiActions.setPanelPosition}
402
294
  open={expand}
403
295
  />
404
296
  )
@@ -413,7 +305,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
413
305
  <div
414
306
  className={css.panel}
415
307
  ref={panelRef}
416
- style={{ left: state.panelPosition.x, top: state.panelPosition.y }}
308
+ style={{ left: ui.panelPosition.x, top: ui.panelPosition.y }}
417
309
  data-theme={style === 'native' ? undefined : style}
418
310
  >
419
311
  <div className={css.dragHandle} onMouseDown={handleDragStart} title={t('task.drag')}>
@@ -421,7 +313,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
421
313
  </div>
422
314
  <header className={css.header}>
423
315
  <div className={css.headerLeft}>
424
- <button
316
+ <Button
425
317
  type="button"
426
318
  className={css.headerIconBtn}
427
319
  onClick={cycleTheme}
@@ -429,30 +321,60 @@ function TaskPanelView({ ctx }: { ctx: any }) {
429
321
  aria-label={styleLabel}
430
322
  >
431
323
  <IconFolderOpenOutline16 className={css.headerIcon} />
432
- </button>
433
- <h2 className={css.headerTitle}>{t('panel.title')}</h2>
324
+ </Button>
325
+ <h2 className={css.headerTitle}>{viewTitle}</h2>
434
326
  </div>
435
327
  <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
328
  <Button
442
329
  variant="ghost"
443
330
  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')}
331
+ onClick={cycleView}
332
+ aria-label={t('view.cycle')}
333
+ title={`${t('view.cycle')}(${view} → ${VIEW_CYCLE[(VIEW_CYCLE.indexOf(view) + 1) % VIEW_CYCLE.length]})`}
449
334
  >
450
- <IconRefreshOutline16 />
335
+ {/* lucide notebook-tabs(视图循环图标) */}
336
+ <svg
337
+ width="16"
338
+ height="16"
339
+ viewBox="0 0 24 24"
340
+ fill="none"
341
+ stroke="currentColor"
342
+ strokeWidth="2"
343
+ strokeLinecap="round"
344
+ strokeLinejoin="round"
345
+ aria-hidden="true"
346
+ >
347
+ <path d="M2 6h4" />
348
+ <path d="M2 10h4" />
349
+ <path d="M2 14h4" />
350
+ <path d="M2 18h4" />
351
+ <rect width="16" height="20" x="4" y="2" rx="2" />
352
+ <path d="M15 2v20" />
353
+ <path d="M15 7h5" />
354
+ <path d="M15 12h5" />
355
+ <path d="M15 17h5" />
356
+ </svg>
451
357
  </Button>
358
+ {view === 'task' && (
359
+ <span className={css.counts}>
360
+ {activeTasks.length > 0
361
+ ? `${t('panel.active')} ${activeTasks.length} · ${doneCount}/${activeTasks.length}`
362
+ : ''}
363
+ </span>
364
+ )}
452
365
  <Button
453
366
  variant="ghost"
454
367
  size="sm"
455
- onClick={() => actions.minimize()}
368
+ onClick={() => setShowHints(!showHints)}
369
+ aria-label={showHints ? t('panel.hints-off') : t('panel.hints-on')}
370
+ title={showHints ? t('panel.hints-off') : t('panel.hints-on')}
371
+ >
372
+ <IconQuestionOutline14 size={16} />
373
+ </Button>
374
+ <Button
375
+ variant="ghost"
376
+ size="sm"
377
+ onClick={() => uiActions.minimize()}
456
378
  aria-label={t('panel.minimize')}
457
379
  title={t('panel.minimize')}
458
380
  >
@@ -461,7 +383,7 @@ function TaskPanelView({ ctx }: { ctx: any }) {
461
383
  <Button
462
384
  variant="ghost"
463
385
  size="sm"
464
- onClick={() => actions.close()}
386
+ onClick={() => uiActions.close()}
465
387
  aria-label={t('panel.close')}
466
388
  title={t('panel.close')}
467
389
  >
@@ -473,7 +395,9 @@ function TaskPanelView({ ctx }: { ctx: any }) {
473
395
  {!sessionId && <div className={css.notice}>{t('panel.no-session')}</div>}
474
396
  {syncError && <div className={css.notice}>{t('panel.sync-failed')}: {syncError}</div>}
475
397
 
476
- {activeTasks.length === 0 ? (
398
+ {view !== 'task' ? (
399
+ <MemoryView ctx={ctx} sessionId={sessionId} scope={view as InsightScope} boundTaskId={data.boundTaskId} t={t} />
400
+ ) : activeTasks.length === 0 ? (
477
401
  <div className={css.emptyState}>
478
402
  <p className={css.emptyTitle}>{t('panel.empty')}</p>
479
403
  <p className={css.emptyDesc}>{t('panel.empty-desc')}</p>
@@ -483,168 +407,27 @@ function TaskPanelView({ ctx }: { ctx: any }) {
483
407
  <div className={css.taskList}>
484
408
  {activeTasks.map((task) => {
485
409
  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
410
+ const expanded = ui.expandedTaskIds.includes(task.id)
411
+ const isBound = data.boundTaskId === task.id
490
412
  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>
413
+ <TaskCard
414
+ key={task.id}
415
+ task={task}
416
+ isBound={isBound}
417
+ expanded={expanded}
418
+ onToggleExpand={() => uiActions.toggleTaskExpanded(task.id)}
419
+ onSwitch={() => handleAction('switch', task.id)}
420
+ onUnbind={() => runLine('/task unbind')}
421
+ onArchive={() => handleAction('archive', task.id)}
422
+ onRename={(value) => commitTitle(task.id, value)}
423
+ onEditStep={(index, value) => commitStepText(task.id, index, value)}
424
+ onCycleStatus={(index) => cycleStatus(task.id, index)}
425
+ onReorderSteps={(from, to) => reorderSteps(task.id, from, to)}
426
+ onInsightAction={(action, id) => void handleInsightTask(action, id)}
427
+ showHints={showHints}
428
+ syncing={syncing}
429
+ t={t}
430
+ />
648
431
  )
649
432
  })}
650
433
  {syncing && <div className={css.syncHint}>{t('panel.syncing')}</div>}
@@ -652,4 +435,4 @@ function TaskPanelView({ ctx }: { ctx: any }) {
652
435
  )}
653
436
  </div>
654
437
  )
655
- }
438
+ }