@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -13,15 +13,34 @@ import { executionLabel, tagTone } from '../../core/tasks.ts'
13
13
  import { t } from '../locales.ts'
14
14
  import css from '../board.module.css'
15
15
 
16
+ /**
17
+ * Built formatters, keyed by time zone ('' = the browser's own). Constructing
18
+ * an Intl.DateTimeFormat is orders of magnitude more expensive than formatting
19
+ * with one, and the board renders dozens of timestamps per SSE frame; the
20
+ * cached formatter keeps that cost off the render path.
21
+ */
22
+ const HOST_TIMESTAMP_FORMATS = new Map<string, Intl.DateTimeFormat>()
23
+
24
+ function hostTimestampFormat(timeZone: string | undefined): Intl.DateTimeFormat {
25
+ const key = timeZone ?? ''
26
+ const cached = HOST_TIMESTAMP_FORMATS.get(key)
27
+ if (cached !== undefined) return cached
28
+ const format = new Intl.DateTimeFormat(undefined, {
29
+ dateStyle: 'medium',
30
+ timeStyle: 'medium',
31
+ ...(timeZone === undefined ? {} : { timeZone }),
32
+ })
33
+ HOST_TIMESTAMP_FORMATS.set(key, format)
34
+ return format
35
+ }
36
+
16
37
  /** Compact relative/absolute time label. */
17
38
  export function formatHostTimestamp(ms: number, timeZone?: string): string {
18
39
  try {
19
- return new Intl.DateTimeFormat(undefined, {
20
- dateStyle: 'medium',
21
- timeStyle: 'medium',
22
- ...(timeZone === undefined ? {} : { timeZone }),
23
- }).format(new Date(ms))
40
+ return hostTimestampFormat(timeZone).format(new Date(ms))
24
41
  } catch {
42
+ // An unsupported time-zone id throws on construction: never cache it, and
43
+ // fall back to the ISO instant for this call.
25
44
  return new Date(ms).toISOString()
26
45
  }
27
46
  }
@@ -5,7 +5,7 @@
5
5
  * execution's session transcript.
6
6
  */
7
7
  import { useEffect, useState } from 'react'
8
- import type { BoardController } from '../../core/controller.ts'
8
+ import type { BoardController, ControllerSnapshot } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
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'
@@ -13,11 +13,13 @@ import { requiresPermissionConfirmation } from '../../core/handover.ts'
13
13
  import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
14
14
  import { t, type TaskBoardKey } from '../locales.ts'
15
15
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
16
+ import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
16
17
  import css from '../board.module.css'
17
18
  import { ConfirmDialog } from './ConfirmDialog.tsx'
18
19
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
19
20
  import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
20
21
  import { NewTaskModal } from './NewTaskModal.tsx'
22
+ import { inheritPresetLabel, presetLabel } from './preset-label.ts'
21
23
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
22
24
  import { STATUS_KEY } from './status-key.ts'
23
25
 
@@ -63,20 +65,16 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
63
65
  }
64
66
 
65
67
  /** The execution-target editor: workspace / mode / permission pickers. */
66
- function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
67
- const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
68
- // Whether this deployment serves the Agent Teams service: without it a team
69
- // run is refused, so the opt-in is offered read-only with the reason.
70
- const teamRunOffered = (): boolean => controller.getSnapshot().host?.teamRunAvailable === true
71
- const [teamRunAvailable, setTeamRunAvailable] = useState(teamRunOffered())
72
- useEffect(
73
- () => controller.subscribe(() => {
74
- const snapshot = controller.getSnapshot()
75
- setOptions(snapshot.executionOptions)
76
- setTeamRunAvailable(snapshot.host?.teamRunAvailable === true)
77
- }),
78
- [controller],
79
- )
68
+ function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
69
+ controller: BoardController
70
+ task: TaskRecord
71
+ pending: boolean
72
+ /** Picker option sets, owned by the detail overlay's snapshot. */
73
+ executionOptions: ControllerSnapshot['executionOptions']
74
+ /** Whether this deployment serves the Agent Teams service. */
75
+ teamRunAvailable: boolean
76
+ }) {
77
+ const options = executionOptions
80
78
  const workspaceId = task.workspaceId ?? ''
81
79
  const mode = task.mode ?? ''
82
80
  const permission = task.permission ?? ''
@@ -107,18 +105,18 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
107
105
  </select>
108
106
  </label>
109
107
  <label className={css.field}>
110
- <span className={css.fieldLabel}>{t('new.mode')}</span>
108
+ <span className={css.fieldLabel}>{t('new.agentPreset')}</span>
111
109
  <select
112
110
  className={css.select}
113
111
  value={mode}
114
112
  disabled={pending}
115
113
  onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
116
114
  >
117
- <option value="">{t('exec.mode.default')}</option>
115
+ <option value="">{inheritPresetLabel(options.presets)}</option>
118
116
  {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
119
117
  {options.presets.map(preset => (
120
118
  <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
121
- {preset.name ?? preset.id}
119
+ {presetLabel(preset)}
122
120
  {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
123
121
  {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
124
122
  </option>
@@ -164,6 +162,16 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
164
162
  <span>{t('exec.reuseSession')}</span>
165
163
  </label>
166
164
  <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
165
+ <label className={css.scheduleToggle}>
166
+ <input
167
+ type="checkbox"
168
+ checked={task.goalRun !== false}
169
+ disabled={pending}
170
+ onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
171
+ />
172
+ <span>{t('exec.goalRun')}</span>
173
+ </label>
174
+ <p className={css.detailText}>{t('exec.goalRunHint')}</p>
167
175
  <label className={css.scheduleToggle}>
168
176
  <input
169
177
  type="checkbox"
@@ -178,7 +186,7 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
178
186
  )
179
187
  }
180
188
 
181
- /** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
189
+ /** The scheduled-runs editor: enable toggle, cron input + presets, zone, next-run info. */
182
190
  function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
183
191
  const schedule = task.schedule
184
192
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
@@ -186,7 +194,12 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
186
194
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
187
195
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
188
196
  const [error, setError] = useState<string | undefined>(undefined)
189
- const timeZone = controller.getSnapshot().host?.scheduler.timeZone
197
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
198
+ // The rule's own zone; '' means "follow the Host zone" (no stored zone).
199
+ const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
200
+ // One clock reading per render pass, so the absolute and relative halves of
201
+ // the label always describe the same instant.
202
+ const now = Date.now()
190
203
 
191
204
  // Keep the editor in sync when the task record changes underneath (the
192
205
  // schedule rolls forward as runs trigger).
@@ -195,8 +208,9 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
195
208
  setEnabled(schedule?.enabled ?? false)
196
209
  setNextRunAt(schedule?.nextRunAt)
197
210
  setLastTriggeredAt(schedule?.lastTriggeredAt)
211
+ setZone(schedule?.timeZone ?? '')
198
212
  setError(undefined)
199
- }, [task.id, schedule?.enabled, schedule?.cron, schedule?.nextRunAt, schedule?.lastTriggeredAt])
213
+ }, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
200
214
 
201
215
  /** Validate + persist the current cron text (Enter or blur). */
202
216
  const saveCron = (value: string): void => {
@@ -210,6 +224,16 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
210
224
  controller.setSchedule(task.id, { cron: trimmed })
211
225
  }
212
226
 
227
+ /**
228
+ * Change the rule's zone. `''` clears the stored zone, which is how a user
229
+ * returns a rule to following the Host zone.
230
+ */
231
+ const changeZone = (value: string): void => {
232
+ setZone(value)
233
+ setError(undefined)
234
+ controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
235
+ }
236
+
213
237
  /** Arm/disarm the schedule (arming first persists the edited cron). */
214
238
  const toggleEnabled = (next: boolean): void => {
215
239
  const trimmed = cron.trim()
@@ -232,12 +256,18 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
232
256
  controller.setSchedule(task.id, { cron: preset })
233
257
  }
234
258
 
259
+ // The zone the rule's wall clock is actually read in: its own when set,
260
+ // otherwise the Host zone. The preview must use the same one the Host will.
261
+ const effectiveZone = zone === '' ? hostTimeZone : zone
235
262
  const nextLabel = !enabled || nextRunAt === undefined
236
263
  ? t('detail.schedule.notScheduled')
237
- : nextRunAt <= Date.now()
264
+ : nextRunAt <= now
238
265
  ? t('detail.schedule.dueSoon')
239
- : formatHostTimestamp(nextRunAt, timeZone)
240
- const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
266
+ : nextRunLabel(nextRunAt, effectiveZone, formatHostTimestamp, now)
267
+ const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, effectiveZone)
268
+ // The rule's own stored zone rides the list even when this runtime's
269
+ // inventory does not carry it, so opening the editor cannot rewrite it.
270
+ const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
241
271
 
242
272
  return (
243
273
  <section className={css.detailSection}>
@@ -276,6 +306,23 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
276
306
  ))}
277
307
  </select>
278
308
  </div>
309
+ {/* A list is used instead of a free-text field so only zones the Host can
310
+ also resolve are offered; the Host zone entry clears the stored value. */}
311
+ <label className={css.scheduleZone}>
312
+ <span>{t('detail.schedule.timeZone')}</span>
313
+ <select
314
+ className={css.schedulePreset}
315
+ value={zone}
316
+ disabled={pending}
317
+ aria-label={t('detail.schedule.timeZone')}
318
+ title={t('detail.schedule.timeZoneHint')}
319
+ onChange={event => { changeZone(event.target.value) }}
320
+ >
321
+ {zones.map(choice => (
322
+ <option key={choice.id === '' ? '__host' : choice.id} value={choice.id}>{choice.label}</option>
323
+ ))}
324
+ </select>
325
+ </label>
279
326
  {error !== undefined && <p className={css.formError}>{error}</p>}
280
327
  <p className={css.scheduleMeta}>
281
328
  {t('detail.schedule.nextRun')} {nextLabel}
@@ -290,17 +337,14 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
290
337
  * and the actions that grow or prune the tree. Every gate here mirrors the
291
338
  * Host lineage gate for affordance only; the Host re-checks the action.
292
339
  */
293
- function SubtaskSection({ controller, task, pending, archived }: {
340
+ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
294
341
  controller: BoardController
295
342
  task: TaskRecord
296
343
  pending: boolean
297
344
  archived: boolean
345
+ /** Detail-overlay snapshot; the overlay already re-renders on every notify. */
346
+ snapshot: ControllerSnapshot
298
347
  }) {
299
- const [snapshot, setSnapshot] = useState(controller.getSnapshot())
300
- useEffect(
301
- () => controller.subscribe(() => setSnapshot(controller.getSnapshot())),
302
- [controller],
303
- )
304
348
  const [showAdd, setShowAdd] = useState(false)
305
349
  const [showLink, setShowLink] = useState(false)
306
350
  const tasks = snapshot.tasks
@@ -440,7 +484,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
440
484
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
441
485
  </section>
442
486
 
443
- <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} />
487
+ <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} snapshot={snapshot} />
444
488
 
445
489
  {current.tags !== undefined && current.tags.length > 0 && (
446
490
  <section className={css.detailSection} data-dsh-part="tags">
@@ -484,7 +528,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
484
528
  <h4>{t('detail.handover')}</h4>
485
529
  <p className={css.detailText}>
486
530
  {t('new.workspace')}: {current.handover.workspaceId ?? t('exec.workspace.recent')}
487
- {' · '}{t('new.mode')}: {current.handover.mode ?? t('exec.mode.default')}
531
+ {' · '}{t('new.agentPreset')}: {current.handover.mode ?? t('exec.mode.inherit')}
488
532
  {' · '}{t('new.permission')}: {current.handover.permission === undefined ? t('exec.permission.default') : t(`exec.permission.${current.handover.permission}` as TaskBoardKey)}
489
533
  </p>
490
534
  <p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
@@ -518,7 +562,13 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
518
562
 
519
563
  {!archived && (
520
564
  <>
521
- <ExecutionSettingsSection controller={controller} task={current} pending={pending} />
565
+ <ExecutionSettingsSection
566
+ controller={controller}
567
+ task={current}
568
+ pending={pending}
569
+ executionOptions={snapshot.executionOptions}
570
+ teamRunAvailable={snapshot.host?.teamRunAvailable === true}
571
+ />
522
572
  <ScheduleSection controller={controller} task={current} pending={pending} />
523
573
  </>
524
574
  )}
@@ -4,7 +4,7 @@
4
4
  * NewTaskModal and the EditTaskModal. State stays in the owning modal; these
5
5
  * are controlled components.
6
6
  */
7
- import type { ReactNode } from 'react'
7
+ import { useState, type ReactNode } from 'react'
8
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'
@@ -45,7 +45,7 @@ export function ModalShell({
45
45
  >
46
46
  <h2 className={css.modalTitle}>{title}</h2>
47
47
 
48
- {children}
48
+ <div className={css.modalBody}>{children}</div>
49
49
 
50
50
  {error !== undefined && <p className={css.formError}>{error}</p>}
51
51
 
@@ -72,6 +72,51 @@ export function ModalShell({
72
72
  )
73
73
  }
74
74
 
75
+ /**
76
+ * One collapsible region of a task form (create/edit dialog UX): the form
77
+ * groups its configuration into a few regions instead of stacking every field
78
+ * in one long column, so a normal-sized dialog needs no scrollbar. A collapsed
79
+ * region keeps a one-line summary of the values it holds, so nothing is hidden
80
+ * without a trace; a region holding a blocking error is forced open by its
81
+ * owner (forceOpen) rather than reporting the error out of sight.
82
+ */
83
+ export function CollapsibleSection({
84
+ title,
85
+ summary,
86
+ defaultOpen = false,
87
+ forceOpen = false,
88
+ children,
89
+ }: {
90
+ title: string
91
+ /** One-line value preview rendered only while the region is collapsed. */
92
+ summary?: string | undefined
93
+ /** Whether the region starts expanded (the owner's default layout). */
94
+ defaultOpen?: boolean
95
+ /** Open regardless of the stored state: an error inside must stay visible. */
96
+ forceOpen?: boolean
97
+ children: ReactNode
98
+ }) {
99
+ const [open, setOpen] = useState(defaultOpen)
100
+ const expanded = forceOpen || open
101
+ return (
102
+ <section className={css.formSection} data-dsh-part="form-section" data-open={expanded ? 'true' : undefined}>
103
+ <button
104
+ type="button"
105
+ className={css.formSectionHeader}
106
+ aria-expanded={expanded}
107
+ onClick={() => { setOpen(value => !value) }}
108
+ >
109
+ <span className={css.formSectionChevron} aria-hidden="true" />
110
+ <span className={css.formSectionTitle}>{title}</span>
111
+ {!expanded && summary !== undefined && summary !== '' && (
112
+ <span className={css.formSectionSummary}>{summary}</span>
113
+ )}
114
+ </button>
115
+ {expanded && <div className={css.formSectionBody}>{children}</div>}
116
+ </section>
117
+ )
118
+ }
119
+
75
120
  /** Title + description + prompt fields shared by the new and edit task forms. */
76
121
  export function TaskContentFields({
77
122
  title,
@@ -106,7 +151,7 @@ export function TaskContentFields({
106
151
  <span className={css.fieldLabel}>{t('new.description')}</span>
107
152
  <textarea
108
153
  className={css.input}
109
- rows={3}
154
+ rows={2}
110
155
  value={description}
111
156
  placeholder={t('new.descriptionPlaceholder')}
112
157
  onChange={event => onDescriptionChange(event.target.value)}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Display-only labels for the agent-preset roster, shared by the task form and
3
+ * the detail view.
4
+ *
5
+ * The deployment's four built-in presets (the `standard` / `ptc` / `minimal` /
6
+ * `cordis` rows the shipped web-app bundle declares) carry no display name in
7
+ * the registry roster, so a picker would list bare ids beside the user presets.
8
+ * The map below only supplies names for those four; any other id — a renamed
9
+ * built-in, a community preset, a user preset — falls back to the roster name
10
+ * and then to the id, so this table can never hide or mislabel a preset.
11
+ */
12
+ import type { ExecutionPresetOption } from '../../core/controller.ts'
13
+ import { t, type TaskBoardKey } from '../locales.ts'
14
+
15
+ /** Built-in preset id -> display-name key (display sugar only). */
16
+ const BUILTIN_PRESET_LABELS: Record<string, TaskBoardKey> = {
17
+ standard: 'preset.builtin.standard',
18
+ ptc: 'preset.builtin.ptc',
19
+ minimal: 'preset.builtin.minimal',
20
+ cordis: 'preset.builtin.cordis',
21
+ }
22
+
23
+ /** Whether one roster row is a shipped built-in preset. */
24
+ export function isBuiltinPreset(id: string): boolean {
25
+ return BUILTIN_PRESET_LABELS[id] !== undefined
26
+ }
27
+
28
+ /** Display label of one preset row: built-in label, roster name, or raw id. */
29
+ export function presetLabel(preset: Pick<ExecutionPresetOption, 'id' | 'name'>): string {
30
+ const builtin = BUILTIN_PRESET_LABELS[preset.id]
31
+ if (builtin !== undefined && (preset.name ?? '').trim() === '') return t(builtin)
32
+ return preset.name ?? preset.id
33
+ }
34
+
35
+ /**
36
+ * Label of the empty ("inherit") choice: names the preset a run actually lands
37
+ * on when the card pins nothing, which is the deployment default the roster
38
+ * marks with `isDefault`.
39
+ */
40
+ export function inheritPresetLabel(presets: readonly ExecutionPresetOption[]): string {
41
+ const fallback = presets.find(preset => preset.isDefault)
42
+ if (fallback === undefined) return t('exec.mode.inherit')
43
+ return t('exec.mode.inheritWith', { preset: presetLabel(fallback) })
44
+ }