@yolk_vat-y/dsh-project-memory 0.4.3 → 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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Task Panel Presentational 组件 — MiniBar(折叠迷你条)与 TaskCard(任务卡)。
3
+ * 纯渲染 + 卡片局部行内编辑/拖拽状态,动作经 props 回调交给容器(TaskPanel.tsx)。
4
+ */
5
+ import { useRef, useState } from 'react'
6
+ import {
7
+ Button,
8
+ IconChevronDownOutline14,
9
+ IconChevronUpOutline14,
10
+ IconCheckOutline16,
11
+ IconPlayOutline16,
12
+ IconFolderOpenOutline16,
13
+ } from '@deepseek-ai/dsh-client-ui-primitives'
14
+ import { createTranslate } from './locales.ts'
15
+ import { useTaskDrag } from './task-hooks.ts'
16
+ import { type Task, type TaskStep } from './task-data-store.ts'
17
+ import css from './TaskPanel.module.css'
18
+
19
+ function timeAgo(iso?: string) {
20
+ if (!iso) return ''
21
+ const diff = Date.now() - new Date(iso).getTime()
22
+ const m = Math.floor(diff / 60000)
23
+ if (m < 1) return '刚刚'
24
+ if (m < 60) return `${m}分钟前`
25
+ const h = Math.floor(m / 60)
26
+ if (h < 24) return `${h}小时前`
27
+ return `${Math.floor(h / 24)}天前`
28
+ }
29
+
30
+ function StepIcon({ status }: { status: TaskStep['status'] }) {
31
+ if (status === 'completed') return <IconCheckOutline16 className={css.stepDone} />
32
+ if (status === 'in_progress') return <IconPlayOutline16 className={css.stepRun} />
33
+ return <span className={css.stepPending} />
34
+ }
35
+
36
+ interface MiniBarProps {
37
+ label: string
38
+ hint: string
39
+ position: { x: number; y: number }
40
+ theme: string
41
+ onMove: (pos: { x: number; y: number }) => void
42
+ open: () => void
43
+ }
44
+
45
+ export function MiniBar({ label, hint, position, theme, onMove, open }: MiniBarProps) {
46
+ const barRef = useRef<HTMLDivElement>(null)
47
+ const down = useRef<{ dx: number; dy: number; sx: number; sy: number; moved: boolean } | null>(null)
48
+
49
+ const onMouseDown = (e: React.MouseEvent) => {
50
+ if (e.button !== 0) return
51
+ const rect = barRef.current?.getBoundingClientRect()
52
+ if (!rect) return
53
+ down.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, sx: e.clientX, sy: e.clientY, moved: false }
54
+ const onMoveEv = (ev: MouseEvent) => {
55
+ const d = down.current
56
+ if (!d) return
57
+ if (!d.moved && Math.abs(ev.clientX - d.sx) + Math.abs(ev.clientY - d.sy) > 4) d.moved = true
58
+ if (d.moved) {
59
+ const w = barRef.current?.offsetWidth ?? 260
60
+ const h = barRef.current?.offsetHeight ?? 34
61
+ const x = Math.max(8, Math.min(window.innerWidth - w - 8, ev.clientX - d.dx))
62
+ const y = Math.max(8, Math.min(window.innerHeight - h - 8, ev.clientY - d.dy))
63
+ onMove({ x, y })
64
+ }
65
+ }
66
+ const onUpEv = () => {
67
+ const d = down.current
68
+ down.current = null
69
+ window.removeEventListener('mousemove', onMoveEv)
70
+ window.removeEventListener('mouseup', onUpEv)
71
+ if (d && !d.moved) open()
72
+ }
73
+ window.addEventListener('mousemove', onMoveEv)
74
+ window.addEventListener('mouseup', onUpEv)
75
+ }
76
+
77
+ return (
78
+ <div
79
+ ref={barRef}
80
+ className={css.miniBar}
81
+ style={{ left: position.x, top: position.y }}
82
+ data-theme={theme === 'native' ? undefined : theme}
83
+ onMouseDown={onMouseDown}
84
+ role="button"
85
+ title={hint}
86
+ aria-label={hint}
87
+ >
88
+ <IconFolderOpenOutline16 className={css.miniIcon} />
89
+ <span className={css.miniText}>{label}</span>
90
+ <IconChevronUpOutline14 className={css.miniChevron} />
91
+ </div>
92
+ )
93
+ }
94
+
95
+ interface TaskCardProps {
96
+ task: Task
97
+ isBound: boolean
98
+ expanded: boolean
99
+ onToggleExpand: () => void
100
+ onSwitch: () => void
101
+ onUnbind: () => void
102
+ onArchive: () => void
103
+ onRename: (value: string) => void
104
+ onEditStep: (index: number, value: string) => void
105
+ onCycleStatus: (index: number) => void
106
+ onReorderSteps: (fromIndex: number, toIndex: number) => void
107
+ showHints: boolean
108
+ syncing: boolean
109
+ t: ReturnType<typeof createTranslate>
110
+ }
111
+
112
+ export function TaskCard({
113
+ task,
114
+ isBound,
115
+ expanded,
116
+ onToggleExpand,
117
+ onSwitch,
118
+ onUnbind,
119
+ onArchive,
120
+ onRename,
121
+ onEditStep,
122
+ onCycleStatus,
123
+ onReorderSteps,
124
+ showHints,
125
+ syncing,
126
+ t,
127
+ }: TaskCardProps) {
128
+ const steps = task.steps || []
129
+ const { dragState, dropTargetIndex, startDrag, cancelDrag } = useTaskDrag(task.id, steps, isBound, onReorderSteps)
130
+
131
+ // 行内编辑状态(本卡片局部):双击进入,Enter/失焦提交,Esc 取消。
132
+ const [stepEdit, setStepEdit] = useState<{ index: number; value: string } | null>(null)
133
+ const [titleEdit, setTitleEdit] = useState<{ value: string } | null>(null)
134
+
135
+ const done = steps.filter((s) => s.status === 'completed').length
136
+ const pct = steps.length > 0 ? Math.round((done / steps.length) * 100) : 0
137
+
138
+ const commitStepEdit = (index: number) => {
139
+ if (!stepEdit || stepEdit.index !== index) return
140
+ const value = stepEdit.value
141
+ setStepEdit(null)
142
+ onEditStep(index, value)
143
+ }
144
+
145
+ const commitTitleEdit = () => {
146
+ if (titleEdit === null) return
147
+ const value = titleEdit.value
148
+ setTitleEdit(null)
149
+ onRename(value)
150
+ }
151
+
152
+ return (
153
+ <article data-task-id={task.id} className={`${css.card}${isBound ? ` ${css.cardBound}` : ''}`}>
154
+ <button
155
+ className={css.cardHead}
156
+ onClick={() => { if (titleEdit !== null) return; onToggleExpand() }}
157
+ aria-expanded={expanded}
158
+ >
159
+ <div className={css.cardTitleRow}>
160
+ {isBound && <span className={css.boundBadge}>{t('task.current')}</span>}
161
+ {isBound && titleEdit !== null ? (
162
+ <input
163
+ className={css.stepInput}
164
+ value={titleEdit.value}
165
+ autoFocus
166
+ onChange={(e) => setTitleEdit({ value: e.target.value })}
167
+ onBlur={commitTitleEdit}
168
+ onKeyDown={(e) => {
169
+ if (e.key === 'Enter') commitTitleEdit()
170
+ else if (e.key === 'Escape') setTitleEdit(null)
171
+ }}
172
+ />
173
+ ) : (
174
+ <span
175
+ className={`${css.cardTitle}${isBound ? ` ${css.cardTitleEditable}` : ''}`}
176
+ title={isBound ? t('task.title-edit') : undefined}
177
+ onDoubleClick={(e) => {
178
+ e.preventDefault()
179
+ e.stopPropagation()
180
+ if (isBound) setTitleEdit({ value: String(task.title ?? '') })
181
+ }}
182
+ >
183
+ {task.title}
184
+ </span>
185
+ )}
186
+ </div>
187
+ <div className={css.cardMeta}>
188
+ {steps.length > 0 && <span className={css.progressText}>{done}/{steps.length}</span>}
189
+ <span className={css.updated}>{timeAgo(task.updatedAt || task.lastActiveAt)}</span>
190
+ <span className={css.chevron}>{expanded ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}</span>
191
+ </div>
192
+ </button>
193
+ {steps.length > 0 && (
194
+ <div className={css.track}>
195
+ <div className={css.trackFill} style={{ width: `${pct}%` }} />
196
+ </div>
197
+ )}
198
+ {expanded && (
199
+ <div className={css.cardBody}>
200
+ <div className={css.section}>
201
+ <div className={css.sectionLabel}>
202
+ {t('task.steps')}
203
+ {steps.length > 0 && <span className={css.sectionCount}>{steps.length}</span>}
204
+ </div>
205
+ {steps.length > 0 ? (
206
+ <>
207
+ <ol className={css.stepsList}>
208
+ {steps.map((step, i) => {
209
+ const stepIsEditing = stepEdit !== null && stepEdit.index === i
210
+ const stepIsDragging = dragState?.dragging && dragState.fromIndex === i
211
+ const isDropTarget = dropTargetIndex === i
212
+ const isDropTargetAfter = dropTargetIndex === i + 1
213
+ return (
214
+ <li
215
+ key={i}
216
+ data-step-row
217
+ className={`${css.stepRow}${stepIsDragging ? ` ${css.stepDragging}` : ''}${isDropTarget ? ` ${css.dropTargetBefore}` : ''}${isDropTargetAfter ? ` ${css.dropTargetAfter}` : ''}`}
218
+ onMouseDown={(e) => {
219
+ if (e.button !== 0) return
220
+ if (!isBound || syncing) return
221
+ // 交互控件(状态图标、编辑输入框等)上不进入拖拽
222
+ const el = e.target as HTMLElement
223
+ if (el.closest('button, input, textarea, a')) return
224
+ startDrag(i, step.content, e)
225
+ }}
226
+ >
227
+ {isBound ? (
228
+ <button
229
+ type="button"
230
+ className={css.stepToggle}
231
+ disabled={syncing}
232
+ onClick={(e) => { e.stopPropagation(); onCycleStatus(i) }}
233
+ title={`${t('step.completed')}/${t('step.in-progress')}/${t('step.pending')}`}
234
+ >
235
+ <StepIcon status={step.status} />
236
+ </button>
237
+ ) : (
238
+ <StepIcon status={step.status} />
239
+ )}
240
+ {stepIsEditing ? (
241
+ <input
242
+ className={css.stepInput}
243
+ value={stepEdit!.value}
244
+ autoFocus
245
+ onChange={(e) => setStepEdit({ index: i, value: e.target.value })}
246
+ onBlur={() => commitStepEdit(i)}
247
+ onKeyDown={(e) => {
248
+ if (e.key === 'Enter') commitStepEdit(i)
249
+ else if (e.key === 'Escape') setStepEdit(null)
250
+ }}
251
+ />
252
+ ) : (
253
+ <span
254
+ className={`${css.stepContent}${step.status === 'completed' ? ` ${css.stepContentDone}` : ''}${step.status === 'in_progress' ? ` ${css.stepContentRun}` : ''}${isBound ? ` ${css.stepEditable}` : ''}`}
255
+ title={isBound && showHints ? t('step.edit-hint') : undefined}
256
+ onDoubleClick={(e) => {
257
+ e.stopPropagation()
258
+ if (isBound) setStepEdit({ index: i, value: String(step.content ?? '') })
259
+ }}
260
+ >
261
+ {step.content}
262
+ </span>
263
+ )}
264
+ </li>
265
+ )
266
+ })}
267
+ </ol>
268
+ {dragState && dragState.dragging && (
269
+ <div
270
+ className={css.dragGhost}
271
+ style={{
272
+ left: dragState.clientX + 12,
273
+ top: dragState.clientY - 20,
274
+ }}
275
+ >
276
+ <div className={css.dragGhostCard}>
277
+ <span className={css.dragGhostContent}>{dragState.stepContent}</span>
278
+ <button
279
+ type="button"
280
+ className={css.dragGhostClose}
281
+ style={{ pointerEvents: 'auto' }}
282
+ onClick={(e) => { e.stopPropagation(); cancelDrag() }}
283
+ title={showHints ? t('drag.cancel') : undefined}
284
+ aria-label={t('drag.cancel')}
285
+ >
286
+ ×
287
+ </button>
288
+ </div>
289
+ </div>
290
+ )}
291
+ </>
292
+ ) : (
293
+ <div className={css.muted}>{t('panel.empty-desc')}</div>
294
+ )}
295
+ </div>
296
+
297
+ {(task.files?.length ?? 0) > 0 && (
298
+ <div className={css.section}>
299
+ <div className={css.sectionLabel}>
300
+ {t('task.files')}
301
+ <span className={css.sectionCount}>{task.files.length}</span>
302
+ </div>
303
+ <ul className={css.filesList}>
304
+ {task.files.slice(0, 12).map((file, i) => (
305
+ <li key={i}>
306
+ <button
307
+ className={css.fileRow}
308
+ onClick={() => navigator.clipboard?.writeText(file.path)}
309
+ title={`${t('file.copy')}: ${file.path}${file.line ? `:${file.line}` : ''}`}
310
+ >
311
+ <span className={css.fileDot} />
312
+ <span className={css.filePath}>{file.path}</span>
313
+ {file.line && <span className={css.fileLine}>:{file.line}</span>}
314
+ </button>
315
+ </li>
316
+ ))}
317
+ {(task.files?.length ?? 0) > 12 && (
318
+ <li className={css.muted}>… 共 {task.files.length} 个</li>
319
+ )}
320
+ </ul>
321
+ </div>
322
+ )}
323
+
324
+ <div className={css.cardFooter}>
325
+ <Button variant="outline" size="sm" disabled={isBound || syncing} onClick={onSwitch}>
326
+ {t('task.switch')}
327
+ </Button>
328
+ {isBound && (
329
+ <Button variant="outline" size="sm" disabled={syncing} onClick={onUnbind} title={t('task.unbind')}>
330
+ {t('task.unbind')}
331
+ </Button>
332
+ )}
333
+ <Button variant="outline" size="sm" disabled={syncing} onClick={onArchive}>
334
+ {t('task.archive')}
335
+ </Button>
336
+ </div>
337
+ </div>
338
+ )}
339
+ </article>
340
+ )
341
+ }
@@ -2,8 +2,7 @@
2
2
  全部用 dsh 别名 token(--dsw-alias-*),亮暗随宿主主题自动切换;
3
3
  轻边框 + 轻阴影,无玻璃拟态、无默认 AI 渐变,与 dsh 控件同语言。 */
4
4
 
5
- .panel,
6
- .pill {
5
+ .panel {
7
6
  /* shell.overlay 默认 click-through,条目自身开启 pointer-events */
8
7
  pointer-events: auto;
9
8
  font-family: inherit;
@@ -102,17 +101,6 @@
102
101
  background: var(--dsw-alias-button-primary-fill, #3a6ef5);
103
102
  white-space: nowrap;
104
103
  }
105
- .softBadge {
106
- flex: none;
107
- font-size: 10px;
108
- line-height: 14px;
109
- padding: 0 6px;
110
- border-radius: 7px;
111
- color: var(--dsw-alias-label-tertiary);
112
- background: var(--dsw-alias-interactive-bg-hover, rgba(127, 127, 127, 0.12));
113
- white-space: nowrap;
114
- }
115
-
116
104
  .notice {
117
105
  margin: 8px 12px 0;
118
106
  padding: 6px 10px;
@@ -238,6 +226,9 @@
238
226
  .cardBody {
239
227
  border-top: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, 0.14));
240
228
  padding: 8px 10px 10px;
229
+ max-height: 300px;
230
+ overflow-y: auto;
231
+ overscroll-behavior: contain;
241
232
  }
242
233
  .section { margin-bottom: 8px; }
243
234
  .section:last-child { margin-bottom: 0; }
@@ -355,17 +346,9 @@
355
346
  margin-top: 8px;
356
347
  }
357
348
 
358
- .spinning svg {
359
- animation: spin 0.9s linear infinite;
360
- }
361
- @keyframes spin {
362
- to { transform: rotate(360deg); }
363
- }
364
-
365
349
  @media (prefers-reduced-motion: reduce) {
366
350
  .panel { animation: none; }
367
- .trackFill, .card, .pill { transition: none; }
368
- .spinning svg { animation-duration: 2.4s; }
351
+ .trackFill, .card { transition: none; }
369
352
  }
370
353
 
371
354
  /* —— 折叠迷你条(顶部可拖拽) —— */
@@ -404,11 +387,6 @@
404
387
  flex: 1 1 auto;
405
388
  min-height: 0;
406
389
  }
407
- .cardBody {
408
- max-height: 300px;
409
- overflow-y: auto;
410
- overscroll-behavior: contain;
411
- }
412
390
  .card {
413
391
  display: flex;
414
392
  flex-direction: column;
@@ -452,9 +430,12 @@
452
430
  outline: 1px dashed var(--dsw-alias-border-l3, rgba(127,127,127,.4));
453
431
  outline-offset: 1px;
454
432
  }
433
+ /* 行内编辑框(步骤/标题共用,单行 input) */
455
434
  .stepInput {
456
- flex: 1;
435
+ flex: 1 1 auto;
457
436
  min-width: 0;
437
+ width: auto;
438
+ box-sizing: border-box;
458
439
  font: inherit;
459
440
  font-size: 12px;
460
441
  line-height: 20px;
@@ -465,19 +446,12 @@
465
446
  padding: 0 5px;
466
447
  }
467
448
 
468
- /* —— 行内编辑框(自动长高) —— */
469
- .stepInput {
470
- display: block;
471
- flex: 1 1 auto;
472
- min-width: 0;
473
- width: auto;
474
- resize: none;
475
- overflow: hidden;
476
- box-sizing: border-box;
477
- white-space: pre-wrap;
478
- word-break: break-word;
479
- line-height: 20px;
449
+ /* —— 步骤拖拽重排:源行半透明 —— */
450
+ .stepDragging {
451
+ opacity: 0.3;
452
+ background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.08));
480
453
  }
454
+
481
455
  .cardTitleEditable {
482
456
  cursor: text;
483
457
  border-radius: 3px;
@@ -562,3 +536,92 @@
562
536
  .headerTitle { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
563
537
  .headerRight { min-width: 0; }
564
538
  .counts { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; margin-left: auto; }
539
+
540
+ /* —— 长按拖拽浮动卡片 —— */
541
+ .dragGhost {
542
+ position: fixed;
543
+ pointer-events: none;
544
+ z-index: 9999;
545
+ transform: translate(-50%, -50%);
546
+ transition: left 0.05s ease, top 0.05s ease;
547
+ }
548
+ .dragGhostCard {
549
+ display: inline-flex;
550
+ align-items: center;
551
+ gap: 8px;
552
+ padding: 8px 12px;
553
+ border-radius: 8px;
554
+ background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base, #fff));
555
+ border: 1px solid var(--dsw-alias-border-l3, rgba(127,127,127,.35));
556
+ box-shadow: 0 8px 24px rgba(0,0,0,.18), 0 2px 8px rgba(0,0,0,.1);
557
+ font-size: 12px;
558
+ line-height: 20px;
559
+ color: var(--dsw-alias-label-primary);
560
+ white-space: nowrap;
561
+ max-width: 280px;
562
+ transform: scale(0.9);
563
+ }
564
+ .dragGhostContent {
565
+ flex: 1;
566
+ min-width: 0;
567
+ overflow: hidden;
568
+ text-overflow: ellipsis;
569
+ word-break: break-word;
570
+ }
571
+ .dragGhostClose {
572
+ flex: none;
573
+ display: inline-flex;
574
+ align-items: center;
575
+ justify-content: center;
576
+ width: 20px;
577
+ height: 20px;
578
+ margin-left: 8px;
579
+ border: none;
580
+ border-radius: 4px;
581
+ background: transparent;
582
+ color: var(--dsw-alias-label-tertiary);
583
+ font-size: 16px;
584
+ line-height: 1;
585
+ cursor: pointer;
586
+ opacity: 0.6;
587
+ transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
588
+ }
589
+ .dragGhostClose:hover {
590
+ opacity: 1;
591
+ color: var(--dsw-alias-label-secondary);
592
+ background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12));
593
+ }
594
+
595
+ /* —— 步骤列表插入指示线 —— */
596
+ .dropTargetBefore { position: relative; }
597
+ .dropTargetBefore::before {
598
+ content: '';
599
+ position: absolute;
600
+ left: 0;
601
+ right: 0;
602
+ top: -1px;
603
+ height: 2px;
604
+ background: var(--dsw-alias-button-primary-fill, #3a6ef5);
605
+ border-radius: 1px;
606
+ box-shadow: 0 0 4px var(--dsw-alias-button-primary-fill, #3a6ef5);
607
+ animation: dropPulse 0.8s ease-in-out infinite;
608
+ }
609
+ .dropTargetAfter { position: relative; }
610
+ .dropTargetAfter::after {
611
+ content: '';
612
+ position: absolute;
613
+ left: 0;
614
+ right: 0;
615
+ bottom: -1px;
616
+ height: 2px;
617
+ background: var(--dsw-alias-button-primary-fill, #3a6ef5);
618
+ border-radius: 1px;
619
+ box-shadow: 0 0 4px var(--dsw-alias-button-primary-fill, #3a6ef5);
620
+ animation: dropPulse 0.8s ease-in-out infinite;
621
+ }
622
+ @keyframes dropPulse {
623
+ 0%, 100% { opacity: 0.6; }
624
+ 50% { opacity: 1; }
625
+ }
626
+
627
+ /* —— header 防重叠 —— */