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.
- package/README.md +305 -288
- package/lib/client.js +985 -931
- package/lib/host/archive-sessions.js +30 -0
- package/lib/host/archive-sessions.js.map +1 -0
- package/lib/host/assets.js +139 -0
- package/lib/host/assets.js.map +1 -0
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/locale.js +17 -0
- package/lib/host/locale.js.map +1 -0
- package/lib/host/routes.js +132 -6
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +2 -0
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/session-sync.js +4 -1
- package/lib/host/session-sync.js.map +1 -1
- package/lib/host/storage-queue.js +14 -0
- package/lib/host/storage-queue.js.map +1 -0
- package/lib/host/storage.js +249 -0
- package/lib/host/storage.js.map +1 -0
- package/lib/host/store.js +34 -7
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +73 -113
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +23 -8
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +83 -27
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/builtin-templates.js +155 -0
- package/lib/shared/builtin-templates.js.map +1 -0
- package/lib/shared/protocol.js +39 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +90 -89
- package/src/client/api.ts +35 -1
- package/src/client/board/SettingsModal.tsx +67 -4
- package/src/client/board/SlashPromptInput.tsx +80 -1
- package/src/client/board/TaskBoard.tsx +16 -11
- package/src/client/board/TaskDetail.tsx +186 -17
- package/src/client/board/TaskFormModal.tsx +8 -5
- package/src/client/board/TemplateManager.tsx +22 -10
- package/src/client/controller.ts +67 -5
- package/src/client/i18n/en.ts +35 -3
- package/src/client/i18n/templates.ts +25 -0
- package/src/client/i18n/zh.ts +35 -3
- package/src/client/image-insert.ts +29 -0
- package/src/client/styles.ts +35 -2
- package/src/host/archive-sessions.ts +18 -0
- package/src/host/assets.ts +120 -0
- package/src/host/execution.ts +4 -1
- package/src/host/locale.ts +44 -0
- package/src/host/routes.ts +132 -7
- package/src/host/scheduler.ts +2 -0
- package/src/host/session-sync.ts +4 -1
- package/src/host/storage-queue.ts +10 -0
- package/src/host/storage.ts +212 -0
- package/src/host/store.ts +40 -12
- package/src/host/templates.ts +38 -66
- package/src/host/tools.ts +28 -11
- package/src/index.ts +88 -33
- package/src/shared/api.ts +32 -3
- package/src/shared/builtin-templates.ts +153 -0
- package/src/shared/protocol.ts +64 -0
- 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<
|
|
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
|
|
4
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 =>
|
|
686
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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>
|