@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.
- package/CHANGELOG.md +33 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/client/client.d.ts +27 -0
- package/client/client.d.ts.map +1 -0
- package/client/client.js +1133 -0
- package/client/client.js.map +1 -0
- package/client/style.css +395 -0
- package/package.json +30 -4
- package/src/client/TaskCommandNode.module.css +27 -0
- package/src/client/TaskCommandNode.tsx +74 -0
- package/src/client/TaskPanel.module.css +564 -0
- package/src/client/TaskPanel.tsx +655 -0
- package/src/client/client.ts +53 -0
- package/src/client/locales.ts +92 -0
- package/src/client/task-store.ts +192 -0
- package/src/commands/task-actions.js +188 -0
- package/src/commands/tasks.js +57 -25
- package/src/enhancer.js +2 -2
- package/src/index.js +6 -2
- package/src/setup/taskbridge.js +28 -0
- package/src/tools/task-tools.js +6 -1
|
@@ -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
|
+
}
|