dsh-taskboard 0.6.6 → 0.7.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.
Files changed (64) hide show
  1. package/README.md +305 -288
  2. package/lib/client.js +985 -931
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/assets.js +139 -0
  6. package/lib/host/assets.js.map +1 -0
  7. package/lib/host/execution.js +3 -0
  8. package/lib/host/execution.js.map +1 -1
  9. package/lib/host/locale.js +17 -0
  10. package/lib/host/locale.js.map +1 -0
  11. package/lib/host/routes.js +132 -6
  12. package/lib/host/routes.js.map +1 -1
  13. package/lib/host/scheduler.js +2 -0
  14. package/lib/host/scheduler.js.map +1 -1
  15. package/lib/host/session-sync.js +4 -1
  16. package/lib/host/session-sync.js.map +1 -1
  17. package/lib/host/storage-queue.js +14 -0
  18. package/lib/host/storage-queue.js.map +1 -0
  19. package/lib/host/storage.js +249 -0
  20. package/lib/host/storage.js.map +1 -0
  21. package/lib/host/store.js +34 -7
  22. package/lib/host/store.js.map +1 -1
  23. package/lib/host/templates.js +73 -113
  24. package/lib/host/templates.js.map +1 -1
  25. package/lib/host/tools.js +23 -8
  26. package/lib/host/tools.js.map +1 -1
  27. package/lib/index.js +83 -27
  28. package/lib/index.js.map +1 -1
  29. package/lib/shared/api.js.map +1 -1
  30. package/lib/shared/builtin-templates.js +155 -0
  31. package/lib/shared/builtin-templates.js.map +1 -0
  32. package/lib/shared/protocol.js +39 -2
  33. package/lib/shared/protocol.js.map +1 -1
  34. package/package.json +90 -89
  35. package/src/client/api.ts +35 -1
  36. package/src/client/board/SettingsModal.tsx +67 -4
  37. package/src/client/board/SlashPromptInput.tsx +80 -1
  38. package/src/client/board/TaskBoard.tsx +16 -11
  39. package/src/client/board/TaskDetail.tsx +186 -17
  40. package/src/client/board/TaskFormModal.tsx +8 -5
  41. package/src/client/board/TemplateManager.tsx +22 -10
  42. package/src/client/controller.ts +67 -5
  43. package/src/client/i18n/en.ts +35 -3
  44. package/src/client/i18n/templates.ts +25 -0
  45. package/src/client/i18n/zh.ts +35 -3
  46. package/src/client/image-insert.ts +29 -0
  47. package/src/client/styles.ts +35 -2
  48. package/src/host/archive-sessions.ts +18 -0
  49. package/src/host/assets.ts +120 -0
  50. package/src/host/execution.ts +4 -1
  51. package/src/host/locale.ts +44 -0
  52. package/src/host/routes.ts +132 -7
  53. package/src/host/scheduler.ts +2 -0
  54. package/src/host/session-sync.ts +4 -1
  55. package/src/host/storage-queue.ts +10 -0
  56. package/src/host/storage.ts +212 -0
  57. package/src/host/store.ts +40 -12
  58. package/src/host/templates.ts +38 -66
  59. package/src/host/tools.ts +28 -11
  60. package/src/index.ts +88 -33
  61. package/src/shared/api.ts +32 -3
  62. package/src/shared/builtin-templates.ts +153 -0
  63. package/src/shared/protocol.ts +64 -0
  64. package/src/shared/version.ts +9 -9
package/src/client/api.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type {
9
9
  ApiResult,
10
+ AttachmentUpload,
10
11
  ChangeEvent,
11
12
  CreateTaskBody,
12
13
  DeleteTaskBody,
@@ -17,10 +18,14 @@ import type {
17
18
  MergeBranchResponse,
18
19
  ModelCatalogResponse,
19
20
  MoveTaskBody,
21
+ MoveTaskResponse,
22
+ SessionArchiveResult,
20
23
  PromptCompletionsResponse,
21
24
  RejectTaskBody,
22
25
  RunTaskBody,
23
26
  SettingsResponse,
27
+ StorageMigrationResult,
28
+ StorageStatus,
24
29
  StateResponse,
25
30
  TaskRecord,
26
31
  TaskTemplate,
@@ -58,6 +63,17 @@ async function post<T>(path: string, body: unknown): Promise<T> {
58
63
  return unwrap<T>(res)
59
64
  }
60
65
 
66
+ /** Upload raw image bytes; a custom header keeps the route outside simple CSRF requests. */
67
+ async function uploadImage(file: Blob): Promise<AttachmentUpload> {
68
+ const res = await fetch('/dsh-taskboard/assets', {
69
+ method: 'POST',
70
+ headers: { 'content-type': file.type, 'x-dsh-taskboard-upload': '1' },
71
+ body: file,
72
+ signal: AbortSignal.timeout(30_000),
73
+ })
74
+ return unwrap<AttachmentUpload>(res)
75
+ }
76
+
61
77
  /** Route client face (the controller consumes this narrow surface). */
62
78
  export interface TaskboardClient {
63
79
  state(): Promise<StateResponse>
@@ -65,10 +81,13 @@ export interface TaskboardClient {
65
81
  create(body: CreateTaskBody): Promise<TaskSummary>
66
82
  get(id: string): Promise<TaskRecord>
67
83
  update(id: string, body: UpdateTaskBody): Promise<TaskSummary>
68
- move(id: string, body: MoveTaskBody): Promise<TaskSummary>
84
+ move(id: string, body: MoveTaskBody): Promise<MoveTaskResponse>
85
+ archiveSessions?(id: string): Promise<SessionArchiveResult>
69
86
  /** Quick-reject (card ✗): back to todo + optional comment, one mutation. */
70
87
  reject(id: string, body: RejectTaskBody): Promise<TaskSummary>
71
88
  comment(id: string, bodyText: string): Promise<CommentRecord>
89
+ /** Persist an image and return the short Markdown-safe URL. */
90
+ uploadImage(file: Blob): Promise<AttachmentUpload>
72
91
  remove(id: string, body: DeleteTaskBody): Promise<{ trashed?: boolean; purged?: boolean }>
73
92
  /** Trigger a manual run (fresh in-project session); `reuse: true` = 续跑. */
74
93
  run(id: string, body?: RunTaskBody): Promise<{ executionId: string; sessionId: string }>
@@ -98,6 +117,11 @@ export interface TaskboardClient {
98
117
  settings(): Promise<SettingsResponse>
99
118
  /** Replace board settings (whole-object semantics; affects new tasks only). */
100
119
  updateSettings(body: UpdateSettingsBody): Promise<SettingsResponse>
120
+ /** Inspect and validate the host-side data directory. */
121
+ storage(): Promise<StorageStatus>
122
+ checkStorage(directory: string): Promise<StorageStatus>
123
+ /** Move ledger, templates, and attachments together. */
124
+ migrateStorage(directory: string): Promise<StorageMigrationResult>
101
125
  /** Prompt completions for skills and slash commands (0.5.5). */
102
126
  promptCompletions(): Promise<PromptCompletionsResponse>
103
127
  /** Model catalog and agent preset roster (0.5.5). */
@@ -115,8 +139,10 @@ export function createClient(): TaskboardClient {
115
139
  get: id => get<TaskRecord>(`/dsh-taskboard/tasks/${encodeURIComponent(id)}`),
116
140
  update: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/update`, body),
117
141
  move: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/move`, body),
142
+ archiveSessions: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/archive-sessions`, {}),
118
143
  reject: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/reject`, body),
119
144
  comment: (id, bodyText) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/comment`, { body: bodyText }),
145
+ uploadImage,
120
146
  remove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/delete`, body),
121
147
  run: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/run`, body ?? {}),
122
148
  cancel: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/cancel`, {}),
@@ -138,6 +164,14 @@ export function createClient(): TaskboardClient {
138
164
  templateDelete: id => post('/dsh-taskboard/templates/delete', { id }),
139
165
  settings: () => get<SettingsResponse>('/dsh-taskboard/settings'),
140
166
  updateSettings: body => post('/dsh-taskboard/settings/update', body),
167
+ storage: () => get<StorageStatus>('/dsh-taskboard/storage'),
168
+ checkStorage: directory => post<StorageStatus>('/dsh-taskboard/storage/check', { directory }),
169
+ migrateStorage: directory => unwrap<StorageMigrationResult>(fetch('/dsh-taskboard/storage/migrate', {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ directory }),
173
+ signal: AbortSignal.timeout(120_000),
174
+ })),
141
175
  promptCompletions: () => get<PromptCompletionsResponse>('/dsh-taskboard/prompt-completions'),
142
176
  modelCatalog: () => get<ModelCatalogResponse>('/dsh-taskboard/model-catalog'),
143
177
  stream(onChange, onGap) {
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Board-settings modal (0.5.0): the user-owned defaults applied when a NEW
3
- * task is created without an explicit choice. Currently one section — 默认执行
4
- * 隔离 (worktree vs original directory); further sections can slot into the
5
- * body below. Saving goes through the host route (whole-object replace) and
3
+ * task is created without an explicit choice, plus the host data-directory
4
+ * migration surface. Saving goes through host routes and
6
5
  * the SSE change stream refreshes every open view.
7
6
  *
8
7
  * @module dsh-taskboard/client/board/SettingsModal
9
8
  */
10
- import { useState } from 'react'
9
+ import { useEffect, useState } from 'react'
11
10
  import type { BoardController } from '../controller.ts'
12
11
  import { DEFAULT_ISOLATION, defaultPermissionOf, defaultSyncExternalSessionsOf, type IsolationMode, type PermissionMode } from '../../shared/protocol.ts'
13
12
  import { useT, type Translate } from '../i18n/runtime.ts'
@@ -32,7 +31,16 @@ export function SettingsModal({ controller }: { controller: BoardController }) {
32
31
  const [draftIso, setDraftIso] = useState<IsolationMode>(currentIso)
33
32
  const [draftSync, setDraftSync] = useState<boolean>(currentSync)
34
33
  const [draftPerm, setDraftPerm] = useState<PermissionMode>(currentPerm)
34
+ const [storagePath, setStoragePath] = useState(state.storage?.currentDirectory ?? '')
35
+ const [storageTouched, setStorageTouched] = useState(false)
36
+ const [storageBusy, setStorageBusy] = useState(false)
35
37
  const dirty = draftIso !== currentIso || draftSync !== currentSync || draftPerm !== currentPerm
38
+ const effectiveStoragePath = storagePath.trim().length === 0 ? state.storage?.defaultDirectory ?? '' : storagePath.trim()
39
+ const storageDirty = state.storage !== undefined && effectiveStoragePath !== state.storage.currentDirectory
40
+
41
+ useEffect(() => {
42
+ if (!storageTouched && state.storage !== undefined) setStoragePath(state.storage.currentDirectory)
43
+ }, [state.storage, storageTouched])
36
44
 
37
45
  const save = (): void => {
38
46
  void controller.updateSettings({
@@ -145,6 +153,61 @@ export function SettingsModal({ controller }: { controller: BoardController }) {
145
153
  {t('set.perm.current', { current: currentPerm === 'read-only' ? t('set.perm.readOnlyName') : currentPerm === 'danger-full-access' ? t('set.perm.fullName') : t('set.perm.writeName') })}
146
154
  </span>
147
155
  </section>
156
+
157
+ <section className="dsh-atb-diag-sec">
158
+ <h4>{t('set.storage.heading')}</h4>
159
+ <p className="dsh-atb-isolation-note">{t('set.storage.hint')}</p>
160
+ <input
161
+ className="dsh-atb-input dsh-atb-storage-path"
162
+ value={storagePath}
163
+ disabled={state.storage === undefined || storageBusy}
164
+ placeholder={state.storage?.defaultDirectory ?? t('set.storage.loading')}
165
+ onChange={e => { setStoragePath(e.target.value); setStorageTouched(true) }}
166
+ />
167
+ {state.storage !== undefined && (
168
+ <div className="dsh-atb-storage-meta">
169
+ <span>{t('set.storage.current', { path: state.storage.currentDirectory })}</span>
170
+ <span>{t('set.storage.assets', { count: state.storage.assetCount, size: (state.storage.assetBytes / 1024 / 1024).toFixed(1) })}</span>
171
+ {state.storage.error !== undefined && <span className="dsh-atb-storage-error">{state.storage.error}</span>}
172
+ </div>
173
+ )}
174
+ <div className="dsh-atb-storage-actions">
175
+ <button
176
+ type="button"
177
+ className="dsh-atb-btn"
178
+ disabled={state.storage === undefined || storageBusy}
179
+ onClick={() => { setStoragePath(state.storage?.defaultDirectory ?? ''); setStorageTouched(true) }}
180
+ >
181
+ {t('set.storage.default')}
182
+ </button>
183
+ <button
184
+ type="button"
185
+ className="dsh-atb-btn"
186
+ disabled={state.storage === undefined || storageBusy || effectiveStoragePath.length === 0}
187
+ onClick={() => {
188
+ setStorageBusy(true)
189
+ void controller.checkStorage(effectiveStoragePath).finally(() => setStorageBusy(false))
190
+ }}
191
+ >
192
+ {t('set.storage.check')}
193
+ </button>
194
+ <button
195
+ type="button"
196
+ className="dsh-atb-btn"
197
+ data-primary="true"
198
+ disabled={!storageDirty || storageBusy}
199
+ onClick={() => {
200
+ if (!window.confirm(t('set.storage.confirm', { from: state.storage?.currentDirectory ?? '', to: effectiveStoragePath }))) return
201
+ setStorageBusy(true)
202
+ void controller.migrateStorage(effectiveStoragePath).then(ok => {
203
+ if (ok) { setStorageTouched(false); setStoragePath(effectiveStoragePath) }
204
+ }).finally(() => setStorageBusy(false))
205
+ }}
206
+ >
207
+ {storageBusy ? t('set.storage.migrating') : t('set.storage.migrate')}
208
+ </button>
209
+ </div>
210
+ </section>
148
211
  </div>
149
212
 
150
213
  <div className="dsh-atb-modal-foot">
@@ -11,6 +11,7 @@ import { createPortal } from 'react-dom'
11
11
  import type { BoardController } from '../controller.ts'
12
12
  import type { PromptCompletionItem } from '../../shared/api.ts'
13
13
  import { useT, type Translate } from '../i18n/runtime.ts'
14
+ import { IMAGE_ACCEPT, acceptedImageFiles, imageAlt, imageMarkdown, insertImageMarkdown } from '../image-insert.ts'
14
15
 
15
16
  /** Default built-in slash commands (descriptions resolve through t at render,
16
17
  * so they follow the GUI language live; host-provided items override by name). */
@@ -65,6 +66,8 @@ export interface SlashPromptInputProps {
65
66
  autoFocus?: boolean
66
67
  className?: string
67
68
  ariaLabel?: string
69
+ allowImages?: boolean
70
+ onUploadingChange?: (uploading: boolean) => void
68
71
  }
69
72
 
70
73
  /**
@@ -81,11 +84,14 @@ export function SlashPromptInput({
81
84
  autoFocus = false,
82
85
  className,
83
86
  ariaLabel,
87
+ allowImages = false,
88
+ onUploadingChange,
84
89
  }: SlashPromptInputProps) {
85
90
  const t = useT()
86
91
  const textareaRef = useRef<HTMLTextAreaElement>(null)
87
92
  const popupRef = useRef<HTMLDivElement>(null)
88
93
  const listRef = useRef<HTMLDivElement>(null)
94
+ const fileInputRef = useRef<HTMLInputElement>(null)
89
95
  // Inline fixed-position style for the portaled popup (set by positionPopup).
90
96
  const [popupStyle, setPopupStyle] = useState<CSSProperties>({})
91
97
 
@@ -106,6 +112,40 @@ export function SlashPromptInput({
106
112
  const [slashQuery, setSlashQuery] = useState('')
107
113
  const [slashStart, setSlashStart] = useState(-1)
108
114
  const [selectedIndex, setSelectedIndex] = useState(0)
115
+ const [uploading, setUploading] = useState(false)
116
+
117
+ const uploadFiles = async (rawFiles: Iterable<File>): Promise<void> => {
118
+ const files = acceptedImageFiles(rawFiles)
119
+ if (!allowImages || controller === undefined || files.length === 0 || uploading) return
120
+ setUploading(true)
121
+ onUploadingChange?.(true)
122
+ const element = textareaRef.current
123
+ let nextValue = element?.value ?? value
124
+ let start = element?.selectionStart ?? nextValue.length
125
+ let end = element?.selectionEnd ?? start
126
+ let changed = false
127
+ try {
128
+ for (const file of files) {
129
+ const asset = await controller.uploadImage(file)
130
+ if (asset === undefined) continue
131
+ const next = insertImageMarkdown(nextValue, start, end, imageMarkdown(asset, imageAlt(file.name, t('image.defaultAlt'))))
132
+ nextValue = next.value
133
+ start = next.cursor
134
+ end = start
135
+ changed = true
136
+ }
137
+ if (changed) {
138
+ onChange(nextValue)
139
+ setTimeout(() => {
140
+ textareaRef.current?.focus()
141
+ textareaRef.current?.setSelectionRange(start, start)
142
+ }, 0)
143
+ }
144
+ } finally {
145
+ setUploading(false)
146
+ onUploadingChange?.(false)
147
+ }
148
+ }
109
149
 
110
150
  // Fetch host completions if controller provided
111
151
  useEffect(() => {
@@ -282,7 +322,7 @@ export function SlashPromptInput({
282
322
  value={value}
283
323
  rows={rows}
284
324
  maxLength={maxLength}
285
- disabled={disabled}
325
+ disabled={disabled || uploading}
286
326
  autoFocus={autoFocus}
287
327
  placeholder={placeholder}
288
328
  aria-label={ariaLabel}
@@ -293,6 +333,25 @@ export function SlashPromptInput({
293
333
  onKeyUp={checkSlashTrigger}
294
334
  onClick={checkSlashTrigger}
295
335
  onKeyDown={handleKeyDown}
336
+ onPaste={e => {
337
+ const files = acceptedImageFiles(e.clipboardData.files)
338
+ if (allowImages && files.length > 0) {
339
+ e.preventDefault()
340
+ void uploadFiles(files)
341
+ }
342
+ }}
343
+ onDragOver={e => {
344
+ // Browsers keep DataTransfer.files empty while dragging over a
345
+ // page; the concrete files become readable only on drop.
346
+ if (allowImages && e.dataTransfer.types.includes('Files')) e.preventDefault()
347
+ }}
348
+ onDrop={e => {
349
+ const files = acceptedImageFiles(e.dataTransfer.files)
350
+ if (allowImages && files.length > 0) {
351
+ e.preventDefault()
352
+ void uploadFiles(files)
353
+ }
354
+ }}
296
355
  />
297
356
 
298
357
  {/* Slash Autocomplete Popup — portaled to document.body so the
@@ -329,6 +388,26 @@ export function SlashPromptInput({
329
388
  )}
330
389
  </div>
331
390
 
391
+ {allowImages && (
392
+ <div className="dsh-atb-image-actions">
393
+ <input
394
+ ref={fileInputRef}
395
+ type="file"
396
+ accept={IMAGE_ACCEPT}
397
+ multiple
398
+ hidden
399
+ onChange={e => {
400
+ if (e.target.files !== null) void uploadFiles(e.target.files)
401
+ e.target.value = ''
402
+ }}
403
+ />
404
+ <button type="button" className="dsh-atb-image-add" disabled={disabled || uploading} onClick={() => fileInputRef.current?.click()}>
405
+ {uploading ? t('image.uploading') : t('image.add')}
406
+ </button>
407
+ <span className="dsh-atb-image-hint">{t('image.hint')}</span>
408
+ </div>
409
+ )}
410
+
332
411
  {/* Bottom helper toolbar */}
333
412
  <div className="dsh-atb-prompt-foot">
334
413
  <span className="dsh-atb-prompt-tip">
@@ -11,6 +11,7 @@ import { MAIN_STATUSES, canTransition } from '../../shared/protocol.ts'
11
11
  import { PLUGIN_VERSION } from '../../shared/version.ts'
12
12
  import { COLUMN_KEYS, URGENCY_KEYS } from './labels.ts'
13
13
  import { useT } from '../i18n/runtime.ts'
14
+ import { localizeBuiltinName, localizeBuiltinTask } from '../i18n/templates.ts'
14
15
  import { fmtTime, isStaleClaim } from './format.ts'
15
16
  import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
16
17
  import { TaskDetail } from './TaskDetail.tsx'
@@ -88,17 +89,21 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
88
89
  <div className="dsh-atb-newmenu-backdrop" onClick={closeMenu} />
89
90
  <div className="dsh-atb-newmenu-list">
90
91
  <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.setComposer(true) }}>{t('board.action.blankTask')}</button>
91
- {state.templates.map(t => (
92
- <button
93
- key={t.id}
94
- type="button"
95
- className="dsh-atb-newmenu-opt"
96
- title={t.task.description !== undefined && t.task.description.length > 0 ? t.task.description.slice(0, 120) : t.name}
97
- onClick={() => { closeMenu(); controller.newFromTemplate(t.task) }}
98
- >
99
- {t.name}
100
- </button>
101
- ))}
92
+ {state.templates.map(t => {
93
+ const name = localizeBuiltinName(t)
94
+ const spec = localizeBuiltinTask(t)
95
+ return (
96
+ <button
97
+ key={t.id}
98
+ type="button"
99
+ className="dsh-atb-newmenu-opt"
100
+ title={spec.description !== undefined && spec.description.length > 0 ? spec.description.slice(0, 120) : name}
101
+ onClick={() => { closeMenu(); controller.newFromTemplate(spec) }}
102
+ >
103
+ {name}
104
+ </button>
105
+ )
106
+ })}
102
107
  <div className="dsh-atb-newmenu-sep" />
103
108
  <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.openTemplateManager() }}>{t('board.action.manageTemplates')}</button>
104
109
  </div>
@@ -7,14 +7,15 @@
7
7
  *
8
8
  * @module dsh-taskboard/client/board/TaskDetail
9
9
  */
10
- import { useEffect, useState, type ReactNode } from 'react'
10
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
11
11
  import type { BoardController } from '../controller.ts'
12
- import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
- import { canTransition, checklistProgress } from '../../shared/protocol.ts'
12
+ import type { CommentRecord, ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
+ import { canTransition, checklistProgress, taskAssociatedSessionIds } from '../../shared/protocol.ts'
14
14
  import { useAlert } from './AlertModal.tsx'
15
15
  import { fmtTime, isStaleClaim } from './format.ts'
16
16
  import { MOVE_KEYS, OUTCOME_KEYS, STATUS_KEYS, URGENCY_KEYS } from './labels.ts'
17
- import { useT } from '../i18n/runtime.ts'
17
+ import { useT, type Translate } from '../i18n/runtime.ts'
18
+ import { IMAGE_ACCEPT, acceptedImageFiles, imageAlt, imageMarkdown, insertImageMarkdown } from '../image-insert.ts'
18
19
 
19
20
  /** Statuses a user may move this task to, per the state machine. */
20
21
  function moveTargets(task: TaskRecord): TaskRecord['status'][] {
@@ -28,6 +29,26 @@ function shortId(id: string | undefined): string {
28
29
  return id.replace(/^session-(taskboard-)?/, '').slice(0, 8)
29
30
  }
30
31
 
32
+ /**
33
+ * Render a comment body, localizing host-generated system messages (0.6.4).
34
+ * System comments carry a `systemKey` (+ flat params, or structured per-repo
35
+ * rows for the multi-repo merge summary); user/agent comments render raw.
36
+ */
37
+ export function commentBody(t: Translate, c: CommentRecord): string {
38
+ if (c.systemKey === undefined) return c.body
39
+ if (c.systemRows !== undefined) {
40
+ const summary = c.systemRows
41
+ .map(r => {
42
+ const label = r.repo === '' ? t('iso.repo.root') : r.repo
43
+ const mark = r.outcome === 'merged' ? '✓' : r.outcome === 'noop' ? '⟲' : '✗'
44
+ return r.outcome === 'failed' && r.error !== undefined ? `${label} ${mark} ${r.error}` : `${label} ${mark}`
45
+ })
46
+ .join(' · ')
47
+ return t(c.systemKey, { summary })
48
+ }
49
+ return t(c.systemKey, c.systemParams)
50
+ }
51
+
31
52
  /** Execution duration between start and end. */
32
53
  function duration(startedAt: number | undefined, endedAt: number | undefined): string {
33
54
  if (startedAt === undefined || endedAt === undefined) return ''
@@ -494,9 +515,13 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
494
515
  export function TaskDetail({ task, controller, now }: { task: TaskRecord; controller: BoardController; now?: number }) {
495
516
  const t = useT()
496
517
  const [comment, setComment] = useState('')
518
+ const [commentUploading, setCommentUploading] = useState(false)
519
+ const commentRef = useRef<HTMLTextAreaElement>(null)
520
+ const commentFileRef = useRef<HTMLInputElement>(null)
497
521
  const [confirmDone, setConfirmDone] = useState(false)
498
522
  const [confirmPurge, setConfirmPurge] = useState(false)
499
523
  const [confirmCancel, setConfirmCancel] = useState(false)
524
+ const [confirmArchive, setConfirmArchive] = useState(false)
500
525
  // Top action buttons (duplicate / save-as-template / run / reuse-run)
501
526
  // share one in-flight guard: a double click used to fire duplicate runs or
502
527
  // copies while the first round-trip was still pending (review P0).
@@ -510,6 +535,9 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
510
535
  const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
511
536
  const sessionExecution = [...task.executions].reverse().find(e => e.sessionId !== undefined)
512
537
  const targetSessionId = runningExecution?.sessionId ?? sessionExecution?.sessionId ?? (task.claimedBy?.startsWith('session-') ? task.claimedBy : undefined)
538
+ const associatedSessions = taskAssociatedSessionIds(task)
539
+ const archiveState = controller.getSnapshot()
540
+ const archiveResult = archiveState.sessionArchive?.taskId === task.id ? archiveState.sessionArchive.result : undefined
513
541
 
514
542
  /** Fire one top action under the shared busy guard; re-enable on settle. */
515
543
  const runAction = (action: () => Promise<unknown>): void => {
@@ -518,6 +546,42 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
518
546
  void action().catch(() => undefined).finally(() => setActionBusy(false))
519
547
  }
520
548
 
549
+ const postComment = (): void => {
550
+ if (commentUploading || comment.trim().length === 0) return
551
+ void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
552
+ }
553
+
554
+ const uploadCommentImages = async (rawFiles: Iterable<File>): Promise<void> => {
555
+ const files = acceptedImageFiles(rawFiles)
556
+ if (files.length === 0 || commentUploading) return
557
+ setCommentUploading(true)
558
+ const element = commentRef.current
559
+ let nextValue = element?.value ?? comment
560
+ let start = element?.selectionStart ?? nextValue.length
561
+ let end = element?.selectionEnd ?? start
562
+ let changed = false
563
+ try {
564
+ for (const file of files) {
565
+ const asset = await controller.uploadImage(file)
566
+ if (asset === undefined) continue
567
+ const next = insertImageMarkdown(nextValue, start, end, imageMarkdown(asset, imageAlt(file.name, t('image.defaultAlt'))))
568
+ nextValue = next.value
569
+ start = next.cursor
570
+ end = start
571
+ changed = true
572
+ }
573
+ if (changed) {
574
+ setComment(nextValue)
575
+ setTimeout(() => {
576
+ commentRef.current?.focus()
577
+ commentRef.current?.setSelectionRange(start, start)
578
+ }, 0)
579
+ }
580
+ } finally {
581
+ setCommentUploading(false)
582
+ }
583
+ }
584
+
521
585
  /** Jump to an execution's session; prompt precisely when it cannot open. */
522
586
  const jumpToSession = (sessionId: string): void => {
523
587
  void controller.openSession(sessionId).then(result => {
@@ -680,10 +744,21 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
680
744
 
681
745
  <ChecklistBlock task={task} controller={controller} />
682
746
 
747
+ {archiveResult !== undefined && (
748
+ <div role="status" className="dsh-atb-confirm">
749
+ <span>{t('detail.move.archiveResult', { n: archiveResult.archived.length })}</span>
750
+ {archiveResult.failed.map(item => <span key={item.sessionId}>{item.sessionId}: {item.error}</span>)}
751
+ {archiveResult.unsupported.length > 0 && <span>{t('detail.move.archiveUnsupported')} {archiveResult.unsupported.join(', ')}</span>}
752
+ </div>
753
+ )}
754
+ {task.status === 'archived' && associatedSessions.length > 0 && archiveState.archiveSessionsSupported && (
755
+ <button type="button" className="dsh-atb-btn" disabled={actionBusy} onClick={() => runAction(() => controller.retryArchiveSessions(task.id))}>{t('detail.move.archiveRetry')}</button>
756
+ )}
683
757
  <div className="dsh-atb-detail-actions">
684
758
  <div className="dsh-atb-movebtns">
685
- {moveTargets(task).map(to => to === 'done'
686
- ? (confirmDone
759
+ {moveTargets(task).map(to => {
760
+ if (to === 'done') {
761
+ return confirmDone
687
762
  ? (
688
763
  <span key={to} className="dsh-atb-confirm">
689
764
  <span className="dsh-atb-confirm-label" data-tone={unchecked > 0 ? 'bad' : undefined}>
@@ -693,12 +768,83 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
693
768
  <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>{t('shared.cancel')}</button>
694
769
  </span>
695
770
  )
696
- : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>)
697
- : (
698
- <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
771
+ : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => { setConfirmDone(true); setConfirmArchive(false) }}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>
772
+ }
773
+ if (to === 'archived') {
774
+ if (confirmArchive) {
775
+ return (
776
+ <span key={to} className="dsh-atb-confirm">
777
+ <span className="dsh-atb-confirm-label">
778
+ {associatedSessions.length === 1
779
+ ? t('detail.move.confirmArchiveSessionWithId', { id: shortId(associatedSessions[0]) })
780
+ : associatedSessions.length > 1
781
+ ? t('detail.move.confirmArchiveSessionCount', { n: associatedSessions.length })
782
+ : t('detail.move.confirmArchive')}
783
+ </span>
784
+ {associatedSessions.length > 0 && <span className="dsh-atb-confirm-label">{associatedSessions.join(', ')}</span>}
785
+ {associatedSessions.length > 0 ? (
786
+ <>
787
+ <button
788
+ type="button"
789
+ className="dsh-atb-btn"
790
+ disabled={!archiveState.archiveSessionsSupported || actionBusy}
791
+ title={!archiveState.archiveSessionsSupported ? t('detail.move.archiveUnsupported') : undefined}
792
+ onClick={() => {
793
+ runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: true }))
794
+ setConfirmArchive(false)
795
+ }}
796
+ >
797
+ {t('detail.move.archiveWithSession')}
798
+ </button>
799
+ <button
800
+ type="button"
801
+ className="dsh-atb-btn"
802
+ onClick={() => {
803
+ runAction(() => controller.move(task.id, task.version, 'archived', { archiveSessions: false }))
804
+ setConfirmArchive(false)
805
+ }}
806
+ >
807
+ {t('detail.move.archiveCardOnly')}
808
+ </button>
809
+ </>
810
+ ) : (
811
+ <button
812
+ type="button"
813
+ className="dsh-atb-btn"
814
+ data-primary="true"
815
+ onClick={() => {
816
+ void controller.move(task.id, task.version, 'archived')
817
+ setConfirmArchive(false)
818
+ }}
819
+ >
820
+ {t('detail.move.confirm')}
821
+ </button>
822
+ )}
823
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmArchive(false)}>{t('shared.cancel')}</button>
824
+ </span>
825
+ )
826
+ }
827
+ return (
828
+ <button
829
+ key={to}
830
+ type="button"
831
+ className="dsh-atb-movebtn"
832
+ data-to={to}
833
+ onClick={() => {
834
+ setConfirmArchive(true)
835
+ setConfirmDone(false)
836
+ }}
837
+ >
699
838
  {t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
700
839
  </button>
701
- ))}
840
+ )
841
+ }
842
+ return (
843
+ <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
844
+ {t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
845
+ </button>
846
+ )
847
+ })}
702
848
  <button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
703
849
  {task.blocked ? t('detail.blocked.unmark') : t('detail.blocked.mark')}
704
850
  </button>
@@ -730,7 +876,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
730
876
  <b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : t('detail.comments.user')}</b>
731
877
  <span>{fmtTime(c.createdAt)}</span>
732
878
  </div>
733
- <div className="dsh-atb-bubble-body">{c.body}</div>
879
+ <div className="dsh-atb-bubble-body"><MarkdownContent text={commentBody(t, c)} /></div>
734
880
  </div>
735
881
  </div>
736
882
  ))}
@@ -738,24 +884,47 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
738
884
  )}
739
885
  <div className="dsh-atb-composer">
740
886
  <textarea
887
+ ref={commentRef}
741
888
  className="dsh-atb-composer-input"
742
889
  value={comment}
743
890
  placeholder={t('detail.composer.placeholder')}
891
+ disabled={commentUploading}
744
892
  onChange={e => setComment(e.target.value)}
745
893
  onKeyDown={e => {
746
- if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && comment.trim().length > 0) {
894
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && comment.trim().length > 0 && !commentUploading) {
747
895
  // T13: keep the draft when the post fails (reject 表单同样保留).
748
- void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
896
+ postComment()
749
897
  }
750
898
  }}
899
+ onPaste={e => {
900
+ const files = acceptedImageFiles(e.clipboardData.files)
901
+ if (files.length > 0) { e.preventDefault(); void uploadCommentImages(files) }
902
+ }}
903
+ onDragOver={e => { if (e.dataTransfer.types.includes('Files')) e.preventDefault() }}
904
+ onDrop={e => {
905
+ const files = acceptedImageFiles(e.dataTransfer.files)
906
+ if (files.length > 0) { e.preventDefault(); void uploadCommentImages(files) }
907
+ }}
751
908
  />
909
+ <input
910
+ ref={commentFileRef}
911
+ type="file"
912
+ accept={IMAGE_ACCEPT}
913
+ multiple
914
+ hidden
915
+ onChange={e => {
916
+ if (e.target.files !== null) void uploadCommentImages(e.target.files)
917
+ e.target.value = ''
918
+ }}
919
+ />
920
+ <button type="button" className="dsh-atb-image-add" disabled={commentUploading} title={t('image.add')} onClick={() => commentFileRef.current?.click()}>
921
+ {commentUploading ? '…' : '🖼'}
922
+ </button>
752
923
  <button
753
924
  type="button"
754
925
  className="dsh-atb-composer-send"
755
- disabled={comment.trim().length === 0}
756
- onClick={() => {
757
- void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
758
- }}
926
+ disabled={comment.trim().length === 0 || commentUploading}
927
+ onClick={postComment}
759
928
  >
760
929
  {t('detail.composer.send')}
761
930
  </button>