@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.
@@ -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
- * Drop blank rows and trim what survives, so the wire always carries a valid
192
- * tag list (the protocol rejects a list with a blank name or an empty array).
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
- const cleaned: TaskTag[] = []
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
+ }
@@ -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 body = await response.json() as T & { error?: string }
21
- if (!response.ok) throw new Error(body.error ?? `task-board request failed: ${response.status}`)
22
- return body
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 (controller.signal.aborted) throw new Error(`task-board Host request timed out after ${REQUEST_TIMEOUT_MS / 1_000}s`)
80
- throw error
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 () => {
@@ -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)
@@ -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',
@@ -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 { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts'
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)