@linxin666/dsh-client-ui-task-board 0.3.22 → 0.3.23
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.i18n.yaml +2 -2
- package/README.md +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +458 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +2553 -156
- package/lib/types/client/board/EditTaskModal.d.ts +6 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +2 -0
- package/lib/types/client/board/TaskForm.d.ts +2 -2
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +25 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/host-ai.d.ts +51 -0
- package/lib/types/host-routes.d.ts +11 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/protocol.d.ts +19 -0
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +36 -0
- package/src/client/board/NewTaskModal.tsx +91 -3
- package/src/client/board/TaskBoard.tsx +89 -0
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +4 -11
- package/src/client/board.module.css +61 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +50 -0
- package/src/core/controller.ts +57 -1
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +25 -3
- package/src/host-routes.ts +68 -3
- package/src/index.ts +26 -1
- package/src/protocol.ts +34 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
import { useEffect, useState } from 'react'
|
|
8
8
|
import type { BoardController } from '../../core/controller.ts'
|
|
9
9
|
import { isValidCron } from '../../core/schedule.ts'
|
|
10
|
-
import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
10
|
+
import { MANUAL_STATUSES, TASK_PERMISSIONS, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
11
11
|
import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
|
|
12
12
|
import { requiresPermissionConfirmation } from '../../core/handover.ts'
|
|
13
13
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
14
14
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
15
15
|
import css from '../board.module.css'
|
|
16
16
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
17
|
-
import { EditTaskModal } from './EditTaskModal.tsx'
|
|
17
|
+
import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
|
|
18
18
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
19
19
|
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
20
20
|
import { STATUS_KEY } from './status-key.ts'
|
|
@@ -269,6 +269,7 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
269
269
|
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
270
270
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
271
271
|
const [showEdit, setShowEdit] = useState(false)
|
|
272
|
+
const [showEditTags, setShowEditTags] = useState(false)
|
|
272
273
|
const [showDuplicate, setShowDuplicate] = useState(false)
|
|
273
274
|
|
|
274
275
|
// Keep the overlay in sync if the task record changes underneath.
|
|
@@ -277,6 +278,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
277
278
|
// A re-used overlay instance must not carry an edit session across tasks.
|
|
278
279
|
useEffect(() => {
|
|
279
280
|
setShowEdit(false)
|
|
281
|
+
setShowEditTags(false)
|
|
280
282
|
setShowDuplicate(false)
|
|
281
283
|
}, [task.id])
|
|
282
284
|
const current = latest
|
|
@@ -320,6 +322,26 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
320
322
|
<p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
|
|
321
323
|
</section>
|
|
322
324
|
|
|
325
|
+
{current.tags !== undefined && current.tags.length > 0 && (
|
|
326
|
+
<section className={css.detailSection} data-dsh-part="tags">
|
|
327
|
+
<h4>{t('new.tags')}</h4>
|
|
328
|
+
<div className={css.cardTags}>
|
|
329
|
+
{current.tags.map(tag => (
|
|
330
|
+
<span
|
|
331
|
+
key={tag.name}
|
|
332
|
+
className={css.cardTag}
|
|
333
|
+
data-tag-tone={tagTone(tag.name)}
|
|
334
|
+
data-dsh-part="tag-badge"
|
|
335
|
+
data-tag-hint={tag.promptPrefix === undefined ? undefined : tag.promptPrefix}
|
|
336
|
+
title={tag.promptPrefix === undefined ? tag.name : tag.promptPrefix}
|
|
337
|
+
>
|
|
338
|
+
{tag.name}
|
|
339
|
+
</span>
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
</section>
|
|
343
|
+
)}
|
|
344
|
+
|
|
323
345
|
{current.freeze !== undefined && (
|
|
324
346
|
<section className={css.detailSection} data-dsh-part="freeze">
|
|
325
347
|
<h4>{t('detail.freeze')}</h4>
|
|
@@ -431,6 +453,16 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
431
453
|
{t('detail.edit')}
|
|
432
454
|
</button>
|
|
433
455
|
)}
|
|
456
|
+
{!archived && !canEditTaskContent(current) && current.status !== 'running' && (
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
className={css.ghostButton}
|
|
460
|
+
disabled={pending}
|
|
461
|
+
onClick={() => { setShowEditTags(true) }}
|
|
462
|
+
>
|
|
463
|
+
{t('detail.editTags')}
|
|
464
|
+
</button>
|
|
465
|
+
)}
|
|
434
466
|
{!archived && (
|
|
435
467
|
<button
|
|
436
468
|
type="button"
|
|
@@ -514,6 +546,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
514
546
|
<EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
|
|
515
547
|
)}
|
|
516
548
|
|
|
549
|
+
{showEditTags && !archived && current.status !== 'running' && (
|
|
550
|
+
<EditTagsModal controller={controller} task={current} onClose={() => { setShowEditTags(false) }} />
|
|
551
|
+
)}
|
|
552
|
+
|
|
517
553
|
{showDuplicate && !archived && (
|
|
518
554
|
<NewTaskModal
|
|
519
555
|
controller={controller}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* are controlled components.
|
|
6
6
|
*/
|
|
7
7
|
import type { ReactNode } from 'react'
|
|
8
|
-
import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, type TaskTag } from '../../core/tasks.ts'
|
|
8
|
+
import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, normalizeTags, type TaskTag } from '../../core/tasks.ts'
|
|
9
9
|
import { t } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
11
11
|
|
|
@@ -188,16 +188,9 @@ export function TaskTagFields({
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
191
|
+
* Clean a tag list via normalizeTags: trim, drop blanks and duplicates, cap
|
|
192
|
+
* lengths and count, so the wire always carries a valid tag list.
|
|
193
193
|
*/
|
|
194
194
|
export function cleanTags(tags: readonly TaskTag[]): TaskTag[] {
|
|
195
|
-
|
|
196
|
-
for (const tag of tags) {
|
|
197
|
-
const name = tag.name.trim()
|
|
198
|
-
if (name === '') continue
|
|
199
|
-
const promptPrefix = tag.promptPrefix?.trim()
|
|
200
|
-
cleaned.push(promptPrefix === undefined || promptPrefix === '' ? { name } : { name, promptPrefix })
|
|
201
|
-
}
|
|
202
|
-
return cleaned
|
|
195
|
+
return normalizeTags(tags) ?? []
|
|
203
196
|
}
|
|
@@ -1240,3 +1240,64 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
1240
1240
|
.tagAddButton {
|
|
1241
1241
|
align-self: flex-start;
|
|
1242
1242
|
}
|
|
1243
|
+
|
|
1244
|
+
/* --- project partition (#1536) ------------------------------------------------ */
|
|
1245
|
+
|
|
1246
|
+
.projectFilter {
|
|
1247
|
+
display: flex;
|
|
1248
|
+
align-items: center;
|
|
1249
|
+
gap: 6px;
|
|
1250
|
+
flex: none;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
.projectFilterLabel {
|
|
1254
|
+
font-size: 12px;
|
|
1255
|
+
color: var(--dsw-alias-label-secondary);
|
|
1256
|
+
white-space: nowrap;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
.projectDialog {
|
|
1260
|
+
display: flex;
|
|
1261
|
+
flex-direction: column;
|
|
1262
|
+
gap: 8px;
|
|
1263
|
+
flex: none;
|
|
1264
|
+
margin-bottom: 8px;
|
|
1265
|
+
padding: 10px 12px;
|
|
1266
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1267
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1268
|
+
border-radius: 10px;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
.projectDialogActions {
|
|
1272
|
+
display: flex;
|
|
1273
|
+
justify-content: flex-end;
|
|
1274
|
+
gap: 8px;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/* --- AI parse of pasted text (#1540) ------------------------------------------ */
|
|
1278
|
+
|
|
1279
|
+
.aiParse {
|
|
1280
|
+
display: flex;
|
|
1281
|
+
flex-direction: column;
|
|
1282
|
+
gap: 6px;
|
|
1283
|
+
padding: 10px 12px;
|
|
1284
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1285
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1286
|
+
border-radius: 10px;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.aiParseRow {
|
|
1290
|
+
display: flex;
|
|
1291
|
+
align-items: center;
|
|
1292
|
+
gap: 8px;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.aiParseRow .select {
|
|
1296
|
+
flex: 1 1 0;
|
|
1297
|
+
min-width: 0;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
.aiParseRow .ghostButton,
|
|
1301
|
+
.aiParseRow .primaryButton {
|
|
1302
|
+
flex: none;
|
|
1303
|
+
}
|
package/src/client/host-api.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { TaskRecord } from '../core/tasks.ts'
|
|
2
|
+
import { t } from './locales.ts'
|
|
2
3
|
import {
|
|
4
|
+
isTaskParseDraft,
|
|
3
5
|
TASK_BOARD_API_PREFIX,
|
|
4
6
|
type TaskBoardAction,
|
|
5
7
|
type TaskBoardActionEnvelope,
|
|
6
8
|
type TaskBoardEventPayload,
|
|
9
|
+
type TaskBoardParseDraft,
|
|
10
|
+
type TaskBoardParseRequest,
|
|
7
11
|
type TaskBoardSnapshot,
|
|
8
12
|
} from '../protocol.ts'
|
|
9
13
|
|
|
@@ -11,15 +15,69 @@ const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
|
|
|
11
15
|
const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
|
|
12
16
|
const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
|
|
13
17
|
const REQUEST_TIMEOUT_MS = 15_000
|
|
18
|
+
/** Re-notify the panel at most this often while the event stream stays broken. */
|
|
19
|
+
const STREAM_ERROR_NOTIFY_MS = 15_000
|
|
20
|
+
/** Mirrors the Host's own parse budget; only used to phrase the timeout. */
|
|
21
|
+
const TASK_PARSE_TIMEOUT_SECONDS = 45
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Failure classes of the Host API, in the language the panel renders
|
|
25
|
+
* (issue #1528). The Host is the only part that knows whether a task-board
|
|
26
|
+
* route exists at all, so a raw fetch/parse error must never reach the user:
|
|
27
|
+
* "Unexpected token 'o', \"not found\" is not valid JSON" is what a missing
|
|
28
|
+
* Host half looks like today.
|
|
29
|
+
*/
|
|
30
|
+
export type HostApiFailure = 'not-mounted' | 'unauthorized' | 'locked' | 'rejected' | 'timeout' | 'unreachable' | 'unexpected'
|
|
31
|
+
|
|
32
|
+
/** Transport failure carrying a stable class next to its user-facing message. */
|
|
33
|
+
export class HostApiError extends Error {
|
|
34
|
+
constructor(readonly failure: HostApiFailure, message: string, readonly status?: number) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'HostApiError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
14
39
|
|
|
15
40
|
function uuid(): string {
|
|
16
41
|
return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
17
42
|
}
|
|
18
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Read one Host response without ever handing a non-JSON body to JSON.parse:
|
|
46
|
+
* the core webserver answers an unmounted `/api/*` path with the plain text
|
|
47
|
+
* "not found", which used to surface as a JavaScript parse error in the panel.
|
|
48
|
+
*/
|
|
19
49
|
async function readJson<T>(response: Response): Promise<T> {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
const text = await response.text()
|
|
51
|
+
let parsed: unknown
|
|
52
|
+
let readable = false
|
|
53
|
+
if (text.trim() !== '') {
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text)
|
|
56
|
+
readable = true
|
|
57
|
+
} catch {
|
|
58
|
+
readable = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const hostError = readable && typeof parsed === 'object' && parsed !== null && typeof (parsed as { error?: unknown }).error === 'string'
|
|
62
|
+
? (parsed as { error: string }).error
|
|
63
|
+
: undefined
|
|
64
|
+
if (response.ok) {
|
|
65
|
+
if (!readable) throw new HostApiError('unexpected', t('board.hostError.unexpected', { status: String(response.status) }), response.status)
|
|
66
|
+
return parsed as T
|
|
67
|
+
}
|
|
68
|
+
if (hostError !== undefined) {
|
|
69
|
+
// The Host answered with a reason of its own. A ledger lock is the one
|
|
70
|
+
// class worth naming in the panel's words; the authentication fence and
|
|
71
|
+
// every other action rejection are reported as they arrive.
|
|
72
|
+
if (hostError === 'forbidden') throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
|
|
73
|
+
if (response.status === 503 || /lock/i.test(hostError)) {
|
|
74
|
+
throw new HostApiError('locked', t('board.hostError.locked', { detail: hostError }), response.status)
|
|
75
|
+
}
|
|
76
|
+
throw new HostApiError('rejected', hostError, response.status)
|
|
77
|
+
}
|
|
78
|
+
if (response.status === 404) throw new HostApiError('not-mounted', t('board.hostError.notMounted'), 404)
|
|
79
|
+
if (response.status === 401 || response.status === 403) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
|
|
80
|
+
throw new HostApiError('unexpected', t('board.hostError.unexpected', { status: String(response.status) }), response.status)
|
|
23
81
|
}
|
|
24
82
|
|
|
25
83
|
export interface TaskBoardHostTransport {
|
|
@@ -27,6 +85,8 @@ export interface TaskBoardHostTransport {
|
|
|
27
85
|
state(): Promise<TaskBoardSnapshot>
|
|
28
86
|
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
|
|
29
87
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
88
|
+
/** One-shot model parse of pasted text (issue #1540). */
|
|
89
|
+
parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>
|
|
30
90
|
}
|
|
31
91
|
|
|
32
92
|
export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
@@ -76,15 +136,65 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
76
136
|
try {
|
|
77
137
|
return await readJson<TaskBoardSnapshot>(await fetch(url, { ...init, signal: controller.signal }))
|
|
78
138
|
} catch (error) {
|
|
79
|
-
if (
|
|
80
|
-
throw
|
|
139
|
+
if (error instanceof HostApiError) throw error
|
|
140
|
+
if (controller.signal.aborted) throw new HostApiError('timeout', t('board.hostError.timeout', { seconds: String(REQUEST_TIMEOUT_MS / 1_000) }))
|
|
141
|
+
// fetch itself failed (server stopped, connection reset, DNS): the panel
|
|
142
|
+
// says so in its own words instead of leaking "Failed to fetch".
|
|
143
|
+
throw new HostApiError('unreachable', t('board.hostError.unreachable'))
|
|
81
144
|
} finally {
|
|
82
145
|
globalThis.clearTimeout(timeout)
|
|
83
146
|
}
|
|
84
147
|
}
|
|
85
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Ask the Host to turn pasted text into task fields. The route answers a
|
|
151
|
+
* typed failure (no model, timeout, unparseable reply) already phrased for
|
|
152
|
+
* the form, so the UI never renders a raw status code (issue #1540).
|
|
153
|
+
*/
|
|
154
|
+
async parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft> {
|
|
155
|
+
let response: Response
|
|
156
|
+
try {
|
|
157
|
+
response = await fetch(`${TASK_BOARD_API_PREFIX}/parse`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
160
|
+
body: JSON.stringify(request),
|
|
161
|
+
...(signal === undefined ? {} : { signal }),
|
|
162
|
+
})
|
|
163
|
+
} catch {
|
|
164
|
+
// A cancelled parse and a stopped Host look the same to fetch; the form
|
|
165
|
+
// checks its own abort flag before showing anything.
|
|
166
|
+
throw new HostApiError('unreachable', t('board.hostError.unreachable'))
|
|
167
|
+
}
|
|
168
|
+
const text = await response.text()
|
|
169
|
+
let parsed: unknown
|
|
170
|
+
let readable = false
|
|
171
|
+
if (text.trim() !== '') {
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(text)
|
|
174
|
+
readable = true
|
|
175
|
+
} catch {
|
|
176
|
+
readable = false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const record = readable && typeof parsed === 'object' && parsed !== null
|
|
180
|
+
? parsed as { code?: unknown; error?: unknown; draft?: unknown }
|
|
181
|
+
: undefined
|
|
182
|
+
if (response.ok) {
|
|
183
|
+
if (record !== undefined && isTaskParseDraft(record.draft)) return record.draft
|
|
184
|
+
throw new HostApiError('unexpected', t('new.aiParseFailed', { error: t('board.hostError.unexpected', { status: String(response.status) }) }), response.status)
|
|
185
|
+
}
|
|
186
|
+
if (response.status === 404) throw new HostApiError('not-mounted', t('new.aiParseUnavailable'), 404)
|
|
187
|
+
if (response.status === 401 || response.status === 403) throw new HostApiError('unauthorized', t('board.hostError.unauthorized'), response.status)
|
|
188
|
+
const code = typeof record?.code === 'string' ? record.code : undefined
|
|
189
|
+
if (code === 'no-model') throw new HostApiError('rejected', t('new.aiParseNoModel'), response.status)
|
|
190
|
+
if (code === 'timeout') throw new HostApiError('timeout', t('new.aiParseTimeout', { seconds: String(TASK_PARSE_TIMEOUT_SECONDS) }), response.status)
|
|
191
|
+
const detail = typeof record?.error === 'string' && record.error !== '' ? record.error : String(response.status)
|
|
192
|
+
throw new HostApiError('rejected', t('new.aiParseFailed', { error: detail }), response.status)
|
|
193
|
+
}
|
|
194
|
+
|
|
86
195
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void {
|
|
87
196
|
const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
|
|
197
|
+
let lastStreamErrorNotify = 0
|
|
88
198
|
events.onmessage = (message: MessageEvent<string>): void => {
|
|
89
199
|
try {
|
|
90
200
|
const parsed = JSON.parse(message.data) as TaskBoardEventPayload
|
|
@@ -94,6 +204,15 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
|
94
204
|
listener()
|
|
95
205
|
}
|
|
96
206
|
}
|
|
207
|
+
// A stream that cannot connect at all (unmounted route, refused socket) is
|
|
208
|
+
// how a Host half that never mounted looks from the browser; nudge the
|
|
209
|
+
// panel into one state read so the failure becomes visible (#1528).
|
|
210
|
+
events.onerror = (): void => {
|
|
211
|
+
const now = Date.now()
|
|
212
|
+
if (now - lastStreamErrorNotify < STREAM_ERROR_NOTIFY_MS) return
|
|
213
|
+
lastStreamErrorNotify = now
|
|
214
|
+
listener()
|
|
215
|
+
}
|
|
97
216
|
const onVisible = (): void => { if (document.visibilityState === 'visible') listener() }
|
|
98
217
|
document.addEventListener('visibilitychange', onVisible)
|
|
99
218
|
return () => {
|
package/src/client/index.ts
CHANGED
|
@@ -230,6 +230,13 @@ export function apply(ctx: ClientContext): void {
|
|
|
230
230
|
}
|
|
231
231
|
pushWorkspaceOptions()
|
|
232
232
|
disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
|
|
233
|
+
// "New project" on the board is the GUI's own add-project call (#1536);
|
|
234
|
+
// the runtime emits the created Workspace through workspaces.list, which
|
|
235
|
+
// refreshes the filter above without a local re-read.
|
|
236
|
+
controller.setWorkspaceCreator(async path => {
|
|
237
|
+
const created = await workspaces.create({ path })
|
|
238
|
+
return { workspaceId: created.workspaceId }
|
|
239
|
+
})
|
|
233
240
|
const pushPresetOptions = async (): Promise<void> => {
|
|
234
241
|
try {
|
|
235
242
|
const roster = await readPresetRoster(ctx, remote)
|
package/src/client/locales.ts
CHANGED
|
@@ -29,6 +29,30 @@ export const zh = {
|
|
|
29
29
|
'board.updated': '更新于',
|
|
30
30
|
'board.created': '创建于',
|
|
31
31
|
'board.hostError': 'Host 操作失败:{error}',
|
|
32
|
+
'board.hostError.notMounted': '任务看板的后台接口没有挂载:宿主可能没有加载该插件,也可能后台数据正被另一个 DSH 实例占用。重启 DSH 服务后再试',
|
|
33
|
+
'board.hostError.unauthorized': '登录状态已失效,请刷新页面后重试',
|
|
34
|
+
'board.hostError.locked': '任务看板的后台数据被占用:{detail}',
|
|
35
|
+
'board.hostError.timeout': '后台 {seconds} 秒内没有响应,请稍后重试',
|
|
36
|
+
'board.hostError.unreachable': '连接不上本机 DSH 服务,请确认它仍在运行',
|
|
37
|
+
'board.hostError.unexpected': '后台返回了无法识别的响应(HTTP {status})',
|
|
38
|
+
'board.project': '项目',
|
|
39
|
+
'board.projectAll': '全部项目',
|
|
40
|
+
'board.projectNew': '新建项目…',
|
|
41
|
+
'board.projectNewPath': '项目目录(宿主上的绝对路径)',
|
|
42
|
+
'board.projectNewPathPlaceholder': '例如 C:\\work\\my-project 或 /home/me/project',
|
|
43
|
+
'board.projectCreate': '创建项目',
|
|
44
|
+
'board.projectCreateFailed': '创建项目失败:{error}',
|
|
45
|
+
'new.aiParse': 'AI 解析',
|
|
46
|
+
'new.aiParseHint': '把从别处复制的一段话粘进来,让模型整理成标题、描述和执行 Prompt;解析结果只填进下面的表单,你可以先改再创建。',
|
|
47
|
+
'new.aiParsePlaceholder': '粘贴要变成任务的内容…',
|
|
48
|
+
'new.aiParseModel': '解析用模型',
|
|
49
|
+
'new.aiParseRun': '解析并填入',
|
|
50
|
+
'new.aiParseCancel': '取消解析',
|
|
51
|
+
'new.aiParseEmpty': '先粘贴一段内容再解析',
|
|
52
|
+
'new.aiParseNoModel': '这个部署还没有可用的模型,请先在 DSH 里配置模型提供商,或换一个模型再试',
|
|
53
|
+
'new.aiParseUnavailable': '后台没有解析接口可用,重启 DSH 服务后再试',
|
|
54
|
+
'new.aiParseTimeout': '模型 {seconds} 秒内没有返回结果,请重试或换一个更快的模型',
|
|
55
|
+
'new.aiParseFailed': '解析失败:{error}',
|
|
32
56
|
'board.retryHost': '重试连接 Host',
|
|
33
57
|
'board.hostMeta': 'Host 时区 {timeZone} · revision {revision}',
|
|
34
58
|
'new.title': '标题',
|
|
@@ -71,6 +95,7 @@ export const zh = {
|
|
|
71
95
|
'detail.permissionConfirmed': '已确认权限绑定 · {time}',
|
|
72
96
|
'detail.title': '任务详情',
|
|
73
97
|
'detail.edit': '编辑',
|
|
98
|
+
'detail.editTags': '编辑标签',
|
|
74
99
|
'detail.duplicate': '复制为新任务',
|
|
75
100
|
'detail.duplicateAndEdit': '修改并新建副本',
|
|
76
101
|
'detail.close': '关闭',
|
|
@@ -194,6 +219,30 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
194
219
|
'board.updated': 'Updated',
|
|
195
220
|
'board.created': 'Created',
|
|
196
221
|
'board.hostError': 'Host action failed: {error}',
|
|
222
|
+
'board.hostError.notMounted': 'The task board Host API is not mounted: the Host may not have loaded the plugin, or another DSH instance holds the ledger. Restart the DSH service and try again',
|
|
223
|
+
'board.hostError.unauthorized': 'This session is no longer signed in; reload the page and try again',
|
|
224
|
+
'board.hostError.locked': 'The task board ledger is locked: {detail}',
|
|
225
|
+
'board.hostError.timeout': 'The Host did not answer within {seconds}s; try again shortly',
|
|
226
|
+
'board.hostError.unreachable': 'Cannot reach the local DSH service; make sure it is still running',
|
|
227
|
+
'board.hostError.unexpected': 'The Host returned an unrecognized response (HTTP {status})',
|
|
228
|
+
'board.project': 'Project',
|
|
229
|
+
'board.projectAll': 'All projects',
|
|
230
|
+
'board.projectNew': 'New project…',
|
|
231
|
+
'board.projectNewPath': 'Project directory (absolute path on the Host)',
|
|
232
|
+
'board.projectNewPathPlaceholder': 'e.g. C:\\work\\my-project or /home/me/project',
|
|
233
|
+
'board.projectCreate': 'Create project',
|
|
234
|
+
'board.projectCreateFailed': 'Could not create the project: {error}',
|
|
235
|
+
'new.aiParse': 'Parse with AI',
|
|
236
|
+
'new.aiParseHint': 'Paste text copied from somewhere else and let a model turn it into a title, description, and run prompt; the result only fills the form below, so you can edit it before creating the task.',
|
|
237
|
+
'new.aiParsePlaceholder': 'Paste the content to turn into a task…',
|
|
238
|
+
'new.aiParseModel': 'Model used for parsing',
|
|
239
|
+
'new.aiParseRun': 'Parse and fill',
|
|
240
|
+
'new.aiParseCancel': 'Cancel parsing',
|
|
241
|
+
'new.aiParseEmpty': 'Paste something to parse first',
|
|
242
|
+
'new.aiParseNoModel': 'This deployment has no model available; configure a model provider in DSH or pick another model',
|
|
243
|
+
'new.aiParseUnavailable': 'The Host has no parse endpoint; restart the DSH service and try again',
|
|
244
|
+
'new.aiParseTimeout': 'The model did not answer within {seconds}s; try again or pick a faster model',
|
|
245
|
+
'new.aiParseFailed': 'Parsing failed: {error}',
|
|
197
246
|
'board.retryHost': 'Retry Host connection',
|
|
198
247
|
'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
|
|
199
248
|
'new.title': 'Title',
|
|
@@ -236,6 +285,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
236
285
|
'detail.permissionConfirmed': 'Permission confirmed · {time}',
|
|
237
286
|
'detail.title': 'Task Detail',
|
|
238
287
|
'detail.edit': 'Edit',
|
|
288
|
+
'detail.editTags': 'Edit tags',
|
|
239
289
|
'detail.duplicate': 'Duplicate Task',
|
|
240
290
|
'detail.duplicateAndEdit': 'Edit as New Copy',
|
|
241
291
|
'detail.close': 'Close',
|
package/src/core/controller.ts
CHANGED
|
@@ -18,7 +18,13 @@ import { applyCreateTask } from './use-cases/task-create.ts'
|
|
|
18
18
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
19
19
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
20
20
|
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
TaskBoardAction,
|
|
23
|
+
TaskBoardEventPayload,
|
|
24
|
+
TaskBoardParseDraft,
|
|
25
|
+
TaskBoardParseRequest,
|
|
26
|
+
TaskBoardSnapshot,
|
|
27
|
+
} from '../protocol.ts'
|
|
22
28
|
|
|
23
29
|
export interface TaskBoardTransport {
|
|
24
30
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
@@ -29,6 +35,11 @@ export interface TaskBoardTransport {
|
|
|
29
35
|
*/
|
|
30
36
|
action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
|
|
31
37
|
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
38
|
+
/**
|
|
39
|
+
* One-shot model parse of pasted text (issue #1540). Optional: a deployment
|
|
40
|
+
* that cannot parse simply omits it, and the form hides the section.
|
|
41
|
+
*/
|
|
42
|
+
parseDraft?(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
/** The sessions face the controller needs for navigation awareness. */
|
|
@@ -57,6 +68,13 @@ export interface ControllerDeps {
|
|
|
57
68
|
transport?: TaskBoardTransport
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Register one host directory as a DSH project (workspace). Wired by the
|
|
73
|
+
* browser apply() to the runtime's workspace controller; without it the board
|
|
74
|
+
* hides its "new project" action instead of offering a dead control (#1536).
|
|
75
|
+
*/
|
|
76
|
+
export type WorkspaceCreator = (path: string) => Promise<{ workspaceId: string }>
|
|
77
|
+
|
|
60
78
|
/** One workspace option the execution-target pickers offer. */
|
|
61
79
|
export interface ExecutionWorkspaceOption {
|
|
62
80
|
workspaceId: string
|
|
@@ -99,6 +117,10 @@ export interface ControllerSnapshot {
|
|
|
99
117
|
/** Picker option sets (workspace list + agent-preset roster). */
|
|
100
118
|
executionOptions: ExecutionOptionsSnapshot
|
|
101
119
|
pendingTaskIds: readonly string[]
|
|
120
|
+
/** Whether the board may offer "register a new project" (issue #1536). */
|
|
121
|
+
canCreateWorkspace?: boolean
|
|
122
|
+
/** Whether this deployment can parse pasted text into task fields (issue #1540). */
|
|
123
|
+
canParseTask?: boolean
|
|
102
124
|
transportError?: string
|
|
103
125
|
host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>
|
|
104
126
|
}
|
|
@@ -139,6 +161,7 @@ export class BoardController {
|
|
|
139
161
|
private archiveView = false
|
|
140
162
|
private selectedTaskId: string | undefined
|
|
141
163
|
private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [], models: [] }
|
|
164
|
+
private workspaceCreator: WorkspaceCreator | undefined
|
|
142
165
|
private listeners = new Set<() => void>()
|
|
143
166
|
private disposers: Array<() => void> = []
|
|
144
167
|
private readonly now: () => number
|
|
@@ -193,6 +216,8 @@ export class BoardController {
|
|
|
193
216
|
selectedTaskId: this.selectedTaskId,
|
|
194
217
|
executionOptions: this.executionOptions,
|
|
195
218
|
pendingTaskIds: [...this.pendingTaskIds],
|
|
219
|
+
...(this.workspaceCreator === undefined ? {} : { canCreateWorkspace: true }),
|
|
220
|
+
...(typeof this.deps.transport?.parseDraft === 'function' ? { canParseTask: true } : {}),
|
|
196
221
|
...(this.transportError === undefined ? {} : { transportError: this.transportError }),
|
|
197
222
|
...(this.hostState === undefined ? {} : { host: this.hostState }),
|
|
198
223
|
}
|
|
@@ -306,6 +331,37 @@ export class BoardController {
|
|
|
306
331
|
this.notify()
|
|
307
332
|
}
|
|
308
333
|
|
|
334
|
+
/** Wire (or clear) the runtime's project registration face (issue #1536). */
|
|
335
|
+
setWorkspaceCreator(creator: WorkspaceCreator | undefined): void {
|
|
336
|
+
this.workspaceCreator = creator
|
|
337
|
+
this.notify()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Register an existing host directory as a DSH project, exactly as the GUI's
|
|
342
|
+
* own "add project" does; the runtime's failure message surfaces unchanged.
|
|
343
|
+
*/
|
|
344
|
+
async createWorkspace(path: string): Promise<{ workspaceId: string }> {
|
|
345
|
+
if (this.workspaceCreator === undefined) throw new Error('workspace creation is unavailable')
|
|
346
|
+
return await this.workspaceCreator(path)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Whether this deployment can parse pasted text into task fields (issue #1540). */
|
|
350
|
+
canParseTask(): boolean {
|
|
351
|
+
return typeof this.deps.transport?.parseDraft === 'function'
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Parse pasted text into task fields through the Host. The transport already
|
|
356
|
+
* phrases every failure for the user, so its message surfaces unchanged.
|
|
357
|
+
*/
|
|
358
|
+
async parseTaskDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft> {
|
|
359
|
+
const transport = this.deps.transport
|
|
360
|
+
const parse = transport?.parseDraft
|
|
361
|
+
if (transport === undefined || parse === undefined) throw new Error('task parsing is unavailable')
|
|
362
|
+
return await parse.call(transport, request, signal)
|
|
363
|
+
}
|
|
364
|
+
|
|
309
365
|
moveTask(id: string, status: TaskStatus): void {
|
|
310
366
|
if (this.deps.transport !== undefined) {
|
|
311
367
|
void this.commitRemote({ kind: 'move', taskId: id, status }, id)
|