@linxin666/dsh-client-ui-task-board 0.3.21 → 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 +5 -0
- package/README.zh.md +5 -0
- package/lib/client.js +723 -29
- package/lib/client.js.map +1 -1
- package/lib/index.js +2629 -158
- package/lib/types/client/board/EditTaskModal.d.ts +7 -1
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskForm.d.ts +18 -0
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +36 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/core/tasks.d.ts +60 -0
- package/lib/types/core/use-cases/task-update.d.ts +8 -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 +52 -3
- package/src/client/board/NewTaskModal.tsx +100 -5
- package/src/client/board/TaskBoard.tsx +150 -6
- package/src/client/board/TaskCard.tsx +17 -1
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +85 -0
- package/src/client/board.module.css +180 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +72 -0
- package/src/core/controller.ts +57 -1
- package/src/core/store.ts +5 -1
- package/src/core/tasks.ts +112 -0
- package/src/core/use-cases/task-update.ts +12 -2
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +25 -3
- package/src/host-routes.ts +68 -3
- package/src/host-runner.ts +24 -1
- package/src/index.ts +26 -1
- package/src/protocol.ts +45 -3
|
@@ -1121,3 +1121,183 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
1121
1121
|
animation: none;
|
|
1122
1122
|
}
|
|
1123
1123
|
}
|
|
1124
|
+
|
|
1125
|
+
/* --- task tags (issue #1521) ---------------------------------------------------
|
|
1126
|
+
* A badge takes its tone from a fixed six-colour palette chosen by hashing the
|
|
1127
|
+
* name, so one label always paints the same way and nothing per-tag has to be
|
|
1128
|
+
* stored. The fill is translucent and the text keeps the theme's own primary
|
|
1129
|
+
* label colour, so badges stay readable on light, dark, and every skin without
|
|
1130
|
+
* depending on a token a skin may not define.
|
|
1131
|
+
* ----------------------------------------------------------------------------- */
|
|
1132
|
+
|
|
1133
|
+
.cardTags {
|
|
1134
|
+
display: flex;
|
|
1135
|
+
flex-wrap: wrap;
|
|
1136
|
+
gap: 4px;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.cardTag {
|
|
1140
|
+
max-width: 100%;
|
|
1141
|
+
padding: 0 7px;
|
|
1142
|
+
overflow: hidden;
|
|
1143
|
+
border: 1px solid var(--dsh-task-tag-border);
|
|
1144
|
+
border-radius: 999px;
|
|
1145
|
+
background: var(--dsh-task-tag-fill);
|
|
1146
|
+
color: var(--dsw-alias-label-primary);
|
|
1147
|
+
font-size: 10px;
|
|
1148
|
+
line-height: 16px;
|
|
1149
|
+
text-overflow: ellipsis;
|
|
1150
|
+
white-space: nowrap;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
.tagFilter {
|
|
1154
|
+
display: flex;
|
|
1155
|
+
flex-wrap: wrap;
|
|
1156
|
+
align-items: center;
|
|
1157
|
+
gap: 6px;
|
|
1158
|
+
margin: 0 0 10px;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
.tagFilterLabel {
|
|
1162
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1163
|
+
font-size: 11px;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
.tagChip {
|
|
1167
|
+
padding: 1px 9px;
|
|
1168
|
+
border: 1px solid var(--dsh-task-tag-border);
|
|
1169
|
+
border-radius: 999px;
|
|
1170
|
+
background: transparent;
|
|
1171
|
+
color: var(--dsw-alias-label-secondary);
|
|
1172
|
+
cursor: pointer;
|
|
1173
|
+
font-family: inherit;
|
|
1174
|
+
font-size: 11px;
|
|
1175
|
+
line-height: 18px;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
.tagChip[data-active='true'] {
|
|
1179
|
+
background: var(--dsh-task-tag-fill);
|
|
1180
|
+
color: var(--dsw-alias-label-primary);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
.cardTag[data-tag-tone='0'],
|
|
1184
|
+
.tagChip[data-tag-tone='0'] {
|
|
1185
|
+
--dsh-task-tag-fill: #4e93e82e;
|
|
1186
|
+
--dsh-task-tag-border: #4e93e866;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
.cardTag[data-tag-tone='1'],
|
|
1190
|
+
.tagChip[data-tag-tone='1'] {
|
|
1191
|
+
--dsh-task-tag-fill: #2ea36a2e;
|
|
1192
|
+
--dsh-task-tag-border: #2ea36a66;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
.cardTag[data-tag-tone='2'],
|
|
1196
|
+
.tagChip[data-tag-tone='2'] {
|
|
1197
|
+
--dsh-task-tag-fill: #d08a2a2e;
|
|
1198
|
+
--dsh-task-tag-border: #d08a2a66;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
.cardTag[data-tag-tone='3'],
|
|
1202
|
+
.tagChip[data-tag-tone='3'] {
|
|
1203
|
+
--dsh-task-tag-fill: #b456c82e;
|
|
1204
|
+
--dsh-task-tag-border: #b456c866;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
.cardTag[data-tag-tone='4'],
|
|
1208
|
+
.tagChip[data-tag-tone='4'] {
|
|
1209
|
+
--dsh-task-tag-fill: #cf5f7a2e;
|
|
1210
|
+
--dsh-task-tag-border: #cf5f7a66;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
.cardTag[data-tag-tone='5'],
|
|
1214
|
+
.tagChip[data-tag-tone='5'] {
|
|
1215
|
+
--dsh-task-tag-fill: #4a9fb52e;
|
|
1216
|
+
--dsh-task-tag-border: #4a9fb566;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
.fieldHint {
|
|
1220
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1221
|
+
font-size: 11px;
|
|
1222
|
+
line-height: 1.4;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
.tagRow {
|
|
1226
|
+
display: flex;
|
|
1227
|
+
align-items: center;
|
|
1228
|
+
gap: 6px;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.tagRow .input {
|
|
1232
|
+
flex: 1 1 0;
|
|
1233
|
+
min-width: 0;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
.tagRow .ghostButton {
|
|
1237
|
+
flex: none;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
.tagAddButton {
|
|
1241
|
+
align-self: flex-start;
|
|
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': '标题',
|
|
@@ -55,11 +79,23 @@ export const zh = {
|
|
|
55
79
|
'detail.handover': '交接包',
|
|
56
80
|
'detail.handover.bundledAt': '打包于 {time}',
|
|
57
81
|
'detail.handover.references': '引用',
|
|
82
|
+
'new.tags': '标签(可选)',
|
|
83
|
+
'new.tagsHint': '标签用于卡片徽章与顶栏筛选;填了「执行提示」的行会在每次执行前注入 Prompt,留空则只作展示。',
|
|
84
|
+
'new.tagName': '标签名',
|
|
85
|
+
'new.tagNamePlaceholder': '例如:工作',
|
|
86
|
+
'new.tagPrompt': '执行提示(可选)',
|
|
87
|
+
'new.tagPromptPlaceholder': '例如:产物归档到 02-工作/',
|
|
88
|
+
'new.tagAdd': '添加标签',
|
|
89
|
+
'new.tagRemove': '移除标签 {name}',
|
|
90
|
+
'board.tagFilter': '按标签筛选',
|
|
91
|
+
'board.tagFilterClear': '清除标签筛选',
|
|
92
|
+
'board.tagEmpty': '没有同时带这些标签的任务',
|
|
58
93
|
'detail.permissionPending': '权限待确认:本卡有效权限({permission})高于会话默认,手动执行与定时调度都会被拒绝,需人工确认后才能运行。',
|
|
59
94
|
'detail.permissionConfirm': '确认权限绑定',
|
|
60
95
|
'detail.permissionConfirmed': '已确认权限绑定 · {time}',
|
|
61
96
|
'detail.title': '任务详情',
|
|
62
97
|
'detail.edit': '编辑',
|
|
98
|
+
'detail.editTags': '编辑标签',
|
|
63
99
|
'detail.duplicate': '复制为新任务',
|
|
64
100
|
'detail.duplicateAndEdit': '修改并新建副本',
|
|
65
101
|
'detail.close': '关闭',
|
|
@@ -183,6 +219,30 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
183
219
|
'board.updated': 'Updated',
|
|
184
220
|
'board.created': 'Created',
|
|
185
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}',
|
|
186
246
|
'board.retryHost': 'Retry Host connection',
|
|
187
247
|
'board.hostMeta': 'Host time zone {timeZone} · revision {revision}',
|
|
188
248
|
'new.title': 'Title',
|
|
@@ -209,11 +269,23 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
209
269
|
'detail.handover': 'Handover bundle',
|
|
210
270
|
'detail.handover.bundledAt': 'Bundled at {time}',
|
|
211
271
|
'detail.handover.references': 'References',
|
|
272
|
+
'new.tags': 'Tags (optional)',
|
|
273
|
+
'new.tagsHint': 'Tags drive the card badges and the board filter; a row with an execution hint is injected ahead of the prompt on every run, an empty one is display-only.',
|
|
274
|
+
'new.tagName': 'Tag name',
|
|
275
|
+
'new.tagNamePlaceholder': 'e.g. Work',
|
|
276
|
+
'new.tagPrompt': 'Execution hint (optional)',
|
|
277
|
+
'new.tagPromptPlaceholder': 'e.g. Archive artefacts under 02-work/',
|
|
278
|
+
'new.tagAdd': 'Add tag',
|
|
279
|
+
'new.tagRemove': 'Remove tag {name}',
|
|
280
|
+
'board.tagFilter': 'Filter by tag',
|
|
281
|
+
'board.tagFilterClear': 'Clear tag filter',
|
|
282
|
+
'board.tagEmpty': 'No task carries all of these tags',
|
|
212
283
|
'detail.permissionPending': 'Permission pending confirmation: this card\'s effective permission ({permission}) is above the session default; manual runs and cron are refused until a human confirms.',
|
|
213
284
|
'detail.permissionConfirm': 'Confirm permission binding',
|
|
214
285
|
'detail.permissionConfirmed': 'Permission confirmed · {time}',
|
|
215
286
|
'detail.title': 'Task Detail',
|
|
216
287
|
'detail.edit': 'Edit',
|
|
288
|
+
'detail.editTags': 'Edit tags',
|
|
217
289
|
'detail.duplicate': 'Duplicate Task',
|
|
218
290
|
'detail.duplicateAndEdit': 'Edit as New Copy',
|
|
219
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)
|
package/src/core/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
12
|
import { isValidCron } from './schedule.ts'
|
|
13
|
-
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
13
|
+
import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
14
|
import type { TaskHandover } from './handover.ts'
|
|
15
15
|
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
16
16
|
import { sanitizeHandover } from './handover.ts'
|
|
@@ -189,6 +189,10 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
189
189
|
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
190
190
|
task.freeze = normalizeFreeze(row.freeze)
|
|
191
191
|
task.handover = normalizeHandover(row.handover)
|
|
192
|
+
// Tags are repaired field by field like the schedule: a malformed entry is
|
|
193
|
+
// dropped, and a list that repairs to nothing clears the field instead of
|
|
194
|
+
// dropping the task row.
|
|
195
|
+
task.tags = normalizeTags(row.tags)
|
|
192
196
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
|
193
197
|
tasks.push(task)
|
|
194
198
|
}
|