@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.
- package/CHANGELOG.md +34 -0
- package/README.md +33 -4
- package/README.zh-CN.md +34 -6
- package/client/client.js +709 -451
- package/client/client.js.map +1 -1
- package/package.json +9 -9
- package/src/client/TaskCommandNode.tsx +14 -22
- package/src/client/TaskComponents.tsx +341 -0
- package/src/client/TaskPanel.module.css +103 -40
- package/src/client/TaskPanel.tsx +104 -381
- package/src/client/locales.ts +6 -0
- package/src/client/task-data-store.ts +136 -0
- package/src/client/task-hooks.ts +169 -0
- package/src/client/task-ui-store.ts +118 -0
- package/src/commands/task-actions.js +9 -7
- package/src/commands/tasks.js +5 -3
- package/src/index.js +4 -3
- package/src/tools/task-tools.js +19 -0
- package/src/client/task-store.ts +0 -192
package/src/client/TaskPanel.tsx
CHANGED
|
@@ -1,55 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Task Panel — dsh web `shell.overlay` 浮动任务面板。
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
77
|
+
const dataActions = useTaskDataActions()
|
|
78
|
+
const uiActions = useTaskUIActions()
|
|
227
79
|
|
|
228
|
-
const activeTasks =
|
|
229
|
-
const boundTask =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
157
|
+
const task = data.tasks.find((tt) => tt.id === taskId)
|
|
302
158
|
const trimmed = value.trim()
|
|
303
|
-
if (!task)
|
|
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
|
-
|
|
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 =
|
|
178
|
+
const task = data.tasks.find((tt) => tt.id === taskId)
|
|
315
179
|
const trimmed = value.trim()
|
|
316
|
-
if (!task)
|
|
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
|
-
|
|
325
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
if (
|
|
376
|
-
|
|
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
|
-
//
|
|
384
|
-
|
|
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 (
|
|
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={
|
|
266
|
+
position={ui.panelPosition}
|
|
400
267
|
theme={style}
|
|
401
|
-
onMove={
|
|
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:
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
<
|
|
315
|
+
<IconQuestionOutline14 size={16} />
|
|
451
316
|
</Button>
|
|
452
317
|
<Button
|
|
453
318
|
variant="ghost"
|
|
454
319
|
size="sm"
|
|
455
|
-
onClick={() =>
|
|
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={() =>
|
|
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
|
|
487
|
-
const
|
|
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
|
-
<
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
+
}
|