@linxin666/dsh-client-ui-task-board 0.4.6 → 0.4.7

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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +14 -6
  3. package/README.zh.md +14 -6
  4. package/lib/client.js +1502 -820
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +674 -75
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +49 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/locales.d.ts +16 -0
  15. package/lib/types/core/card-defaults.d.ts +78 -0
  16. package/lib/types/core/controller.d.ts +32 -7
  17. package/lib/types/core/launch-width.d.ts +31 -0
  18. package/lib/types/core/reasoning-effort.d.ts +57 -0
  19. package/lib/types/core/subtask.d.ts +30 -8
  20. package/lib/types/core/tasks.d.ts +19 -0
  21. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/launch-admission.d.ts +72 -0
  24. package/lib/types/host-ledger.d.ts +9 -4
  25. package/lib/types/host-runner.d.ts +79 -4
  26. package/lib/types/host-service.d.ts +85 -2
  27. package/lib/types/index.d.ts +87 -0
  28. package/lib/types/protocol.d.ts +13 -0
  29. package/package.json +28 -28
  30. package/src/client/CardDefaultPresetField.tsx +101 -0
  31. package/src/client/TaskBoardSettingsCard.tsx +214 -16
  32. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  33. package/src/client/board/NewTaskModal.tsx +86 -40
  34. package/src/client/board/ReasoningEffortField.tsx +50 -0
  35. package/src/client/board/TaskDetail.tsx +42 -6
  36. package/src/client/board/model-options.ts +178 -0
  37. package/src/client/board/preset-label.ts +32 -0
  38. package/src/client/board/preset-roster.ts +58 -0
  39. package/src/client/board/team-run-promotion.ts +39 -0
  40. package/src/client/index.ts +98 -94
  41. package/src/client/locales.ts +34 -2
  42. package/src/client/settings-entry-form.ts +0 -1
  43. package/src/core/card-defaults.ts +132 -0
  44. package/src/core/controller.ts +44 -9
  45. package/src/core/launch-width.ts +38 -0
  46. package/src/core/reasoning-effort.ts +92 -0
  47. package/src/core/store.ts +5 -0
  48. package/src/core/subtask.ts +48 -13
  49. package/src/core/tasks.ts +20 -0
  50. package/src/core/use-cases/task-create.ts +27 -16
  51. package/src/core/use-cases/task-update.ts +6 -1
  52. package/src/host/agent-tools.ts +21 -8
  53. package/src/host/launch-admission.ts +140 -0
  54. package/src/host-ledger.ts +18 -6
  55. package/src/host-runner.ts +184 -18
  56. package/src/host-service.ts +247 -23
  57. package/src/index.ts +149 -2
  58. package/src/protocol.ts +28 -4
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { useEffect, useRef, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
+ import { resolveCardTargets, type CardTargetSource } from '../../core/card-defaults.ts'
7
8
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
9
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
10
  import { effectiveTaskPermission } from '../../core/subtask.ts'
@@ -15,7 +16,10 @@ import { CollapsibleSection, ModalShell, TaskContentFields, TaskTagFields, clean
15
16
  import type { OverlayPhase } from './overlay.tsx'
16
17
  import { formatHostTimestamp } from './TaskCard.tsx'
17
18
  import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
18
- import { inheritPresetLabel, isBuiltinPreset, presetLabel } from './preset-label.ts'
19
+ import { inheritPresetLabel, presetChoiceRows, presetLabel } from './preset-label.ts'
20
+ import { ReasoningEffortField } from './ReasoningEffortField.tsx'
21
+ import { reconcileReasoningEffort } from './model-options.ts'
22
+ import { parentAwaitsTeamRunPromotion, promoteParentForTeamRun } from './team-run-promotion.ts'
19
23
  import css from '../board.module.css'
20
24
 
21
25
  export interface NewTaskModalProps {
@@ -46,15 +50,32 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
46
50
  const [title, setTitle] = useState(initialTask?.title ?? '')
47
51
  const [description, setDescription] = useState(initialTask?.description ?? '')
48
52
  const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
53
+ // The four execution targets this dialog opens on, resolved by the one rule
54
+ // the Host create path also uses (core/card-defaults.ts): what the card is
55
+ // created from (a duplicate's source, a subtask's parent) first, the board's
56
+ // new-card defaults second, blank when neither supplies a value. Resolving it
57
+ // from the SAME function the ledger uses is what keeps the dialog from
58
+ // drifting from what a create without an explicit choice stores.
59
+ const [seed] = useState(() => {
60
+ const source: CardTargetSource | undefined = parentTask !== undefined
61
+ ? { kind: 'parent', task: parentTask }
62
+ : initialTask !== undefined ? { kind: 'duplicate', task: initialTask } : undefined
63
+ return resolveCardTargets(controller.getSnapshot().host?.cardDefaults, source)
64
+ })
49
65
  // A subtask starts from its parent's execution contract (issue: subtask
50
66
  // inheritance); each picker below still overrides it for this card. The
51
- // permission stays UNSET by default: leaving it empty is what makes the Host
52
- // inherit the parent's binding together with its human confirmation, while
53
- // picking a value pins this card's own binding and re-arms the gate.
67
+ // permission stays UNSET unless a default supplies one: leaving it empty is
68
+ // what makes the Host inherit the parent's binding together with its human
69
+ // confirmation, while picking a value pins this card's own binding and
70
+ // re-arms the gate. A parent's permission never travels here at all.
54
71
  const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? parentTask?.workspaceId ?? defaultWorkspaceId ?? '')
55
- const [mode, setMode] = useState(initialTask?.mode ?? parentTask?.mode ?? '')
56
- const [permission, setPermission] = useState(initialTask?.permission ?? '')
57
- const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
72
+ const [mode, setMode] = useState(seed.mode)
73
+ const [permission, setPermission] = useState(seed.permission)
74
+ const [model, setModel] = useState(seed.model)
75
+ // The effort belongs to the model pin: the resolved seed carries the source
76
+ // card's pair and applies the configured default level only while the final
77
+ // model IS the configured default model; '' follows the model/Host default.
78
+ const [reasoningEffort, setReasoningEffort] = useState(seed.reasoningEffort)
58
79
  const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
59
80
  const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
60
81
  // Unchecked by default: a new task runs one plain turn unless the user opts
@@ -100,14 +121,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
100
121
  const [parsePending, setParsePending] = useState(false)
101
122
  const [parseError, setParseError] = useState<string | undefined>(undefined)
102
123
  const parseAbort = useRef<AbortController | undefined>(undefined)
103
- const parseModels = options.models ?? []
124
+ const models = options.models ?? []
104
125
  // A pinned target may have disappeared from the runtime (workspace deleted,
105
126
  // preset removed) — especially when the form starts from a subtask's parent.
106
127
  // Keep it selectable as a stale row, so the field shows what the create
107
128
  // action will actually send instead of silently displaying the default.
108
129
  const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
109
130
  const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
110
- const modelKnown = model === '' || parseModels.some(item => item.id === model)
131
+ const modelKnown = model === '' || models.some(item => item.id === model)
111
132
 
112
133
  // The workspace list and preset roster arrive from the runtime after mount;
113
134
  // follow them so the pickers never freeze on an empty snapshot.
@@ -124,8 +145,8 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
124
145
  // no longer offers falls back to the Host default; an empty value is the
125
146
  // Host-default choice and is never overwritten by the roster (issue #1621).
126
147
  useEffect(() => {
127
- if (parseModel === '' || parseModels.length === 0) return
128
- if (parseModels.some(option => option.id === parseModel)) return
148
+ if (parseModel === '' || models.length === 0) return
149
+ if (models.some(option => option.id === parseModel)) return
129
150
  setParseModel('')
130
151
  writeParseModelPreference('')
131
152
  }, [parseModel, options.models])
@@ -215,10 +236,15 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
215
236
  ...(parentTask === undefined ? {} : { parentId: parentTask.id }),
216
237
  freeze,
217
238
  handover,
218
- workspaceId: workspaceId === '' ? undefined : workspaceId,
219
- mode: mode === '' ? undefined : mode,
220
- permission: permission === '' ? undefined : permission as TaskPermission,
221
- model: model === '' ? undefined : model,
239
+ // A cleared picker is OMITTED, never sent as an empty value: absence is
240
+ // how "no pin, resolved at run time" is stored, so blanking a field the
241
+ // dialog opened prefilled really unpins it. This dialog never sends
242
+ // `applyCardDefaults` — the prefill above is the whole feature here.
243
+ ...(workspaceId === '' ? {} : { workspaceId }),
244
+ ...(mode === '' ? {} : { mode }),
245
+ ...(permission === '' ? {} : { permission: permission as TaskPermission }),
246
+ ...(model === '' ? {} : { model }),
247
+ ...(reasoningEffort === '' ? {} : { reasoningEffort }),
222
248
  ...(reuseSession ? { reuseSession: true } : {}),
223
249
  ...(goalRun ? { goalRun: true } : {}),
224
250
  ...(skipVerification ? { skipVerification: true } : {}),
@@ -247,6 +273,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
247
273
  setError(controller.getSnapshot().transportError ?? t('new.required'))
248
274
  return
249
275
  }
276
+ // The join is confirmed, so a parent that just gained a subtask is opted in
277
+ // where Agent Teams are served. The write is a convenience, not part of the
278
+ // creation: a refused one (the card was archived meanwhile) already
279
+ // surfaced through the snapshot and never fails the created task.
280
+ await promoteParentForTeamRun(controller, parentTask)
250
281
  if (isDuplicate && archiveOriginal && initialTask !== undefined) {
251
282
  if (onDuplicateSuccess !== undefined) {
252
283
  await onDuplicateSuccess(initialTask.id)
@@ -283,10 +314,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
283
314
  : isDuplicate ? t('new.duplicateTitle') : t('board.new')
284
315
 
285
316
  // Agent-preset picker rows: the four built-in presets first, then every other
286
- // roster row (user presets). The inherit option names the preset a run
287
- // actually lands on when the card pins nothing.
288
- const builtinPresets = options.presets.filter(preset => isBuiltinPreset(preset.id))
289
- const customPresets = options.presets.filter(preset => !isBuiltinPreset(preset.id))
317
+ // roster row (user presets), each labelled with its default/unavailable
318
+ // suffixes by the one row builder the settings card's default-preset field
319
+ // also renders through. The inherit option names the preset a run actually
320
+ // lands on when the card pins nothing.
321
+ const presetRows = presetChoiceRows(options.presets)
290
322
  const inheritLabel = inheritPresetLabel(options.presets)
291
323
  const selectedPreset = options.presets.find(preset => preset.id === mode)
292
324
  const modeLabel = mode === '' ? inheritLabel : selectedPreset === undefined ? mode : presetLabel(selectedPreset)
@@ -301,6 +333,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
301
333
  const modelLabel = model === ''
302
334
  ? t('exec.model.default')
303
335
  : (options.models ?? []).find(item => item.id === model)?.name ?? model
336
+ // The effort is part of the model pin, so a pinned one rides the model's own
337
+ // summary entry instead of standing as a region-level value of its own.
338
+ const modelSummary = reasoningEffort === '' ? modelLabel : `${modelLabel} · ${reasoningEffort}`
304
339
  // Collapsed-region summaries: what each closed region currently holds, so a
305
340
  // collapse never hides the configuration without a trace.
306
341
  // A host-default model adds only the long parenthetical name to the
@@ -309,7 +344,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
309
344
  workspaceLabel,
310
345
  modeLabel,
311
346
  permissionLabel,
312
- ...(model === '' ? [] : [modelLabel]),
347
+ ...(model === '' ? [] : [modelSummary]),
313
348
  ].join(' · ')
314
349
  const tagCount = cleanTags(tags).length
315
350
  const labelsSummary = tagCount === 0 ? t('new.summary.none') : t('new.summary.labelCount', { count: String(tagCount) })
@@ -378,7 +413,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
378
413
  }}
379
414
  >
380
415
  <option value="">{t('exec.model.default')}</option>
381
- {parseModels.map(option => (
416
+ {models.map(option => (
382
417
  <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
383
418
  ))}
384
419
  </select>
@@ -405,9 +440,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
405
440
  )}
406
441
 
407
442
  {parentTask !== undefined && (
408
- <p className={css.detailText} data-dsh-part="subtask-inherit">
409
- {t('detail.parent')}: {parentTask.title} · {t('new.subtaskInherit')}
410
- </p>
443
+ <>
444
+ <p className={css.detailText} data-dsh-part="subtask-inherit">
445
+ {t('detail.parent')}: {parentTask.title} · {t('new.subtaskInherit')}
446
+ </p>
447
+ {parentAwaitsTeamRunPromotion(controller, parentTask) && (
448
+ <p className={css.detailText}>{t('exec.teamRunAutoHint')}</p>
449
+ )}
450
+ </>
411
451
  )}
412
452
 
413
453
  <CollapsibleSection title={t('new.section.content')} defaultOpen>
@@ -460,25 +500,17 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
460
500
  >
461
501
  <option value="">{inheritLabel}</option>
462
502
  {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
463
- {builtinPresets.length > 0 && (
503
+ {presetRows.builtin.length > 0 && (
464
504
  <optgroup label={t('exec.mode.builtinGroup')}>
465
- {builtinPresets.map(preset => (
466
- <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
467
- {presetLabel(preset)}
468
- {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
469
- {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
470
- </option>
505
+ {presetRows.builtin.map(row => (
506
+ <option key={row.id} value={row.id} disabled={row.disabled}>{row.label}</option>
471
507
  ))}
472
508
  </optgroup>
473
509
  )}
474
- {customPresets.length > 0 && (
510
+ {presetRows.custom.length > 0 && (
475
511
  <optgroup label={t('exec.mode.customGroup')}>
476
- {customPresets.map(preset => (
477
- <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
478
- {presetLabel(preset)}
479
- {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
480
- {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
481
- </option>
512
+ {presetRows.custom.map(row => (
513
+ <option key={row.id} value={row.id} disabled={row.disabled}>{row.label}</option>
482
514
  ))}
483
515
  </optgroup>
484
516
  )}
@@ -506,15 +538,29 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
506
538
  <select
507
539
  className={css.select}
508
540
  value={model}
509
- onChange={event => { setModel(event.target.value) }}
541
+ onChange={event => {
542
+ // The effort belongs to the model: switching to a model that
543
+ // cannot honor the picked level drops it instead of carrying it.
544
+ const next = event.target.value
545
+ setModel(next)
546
+ setReasoningEffort(current => reconcileReasoningEffort(models, next, current))
547
+ }}
510
548
  >
511
549
  <option value="">{t('exec.model.default')}</option>
512
550
  {!modelKnown && <option value={model}>{model}{t('exec.model.unknown')}</option>}
513
- {options.models?.map(item => (
551
+ {models.map(item => (
514
552
  <option key={item.id} value={item.id}>{item.name ?? item.id}</option>
515
553
  ))}
516
554
  </select>
517
555
  </label>
556
+
557
+ <ReasoningEffortField
558
+ models={models}
559
+ modelId={model}
560
+ value={reasoningEffort}
561
+ pending={pending}
562
+ onChange={setReasoningEffort}
563
+ />
518
564
  </CollapsibleSection>
519
565
 
520
566
  <CollapsibleSection title={t('new.section.run')} summary={runSummary}>
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Reasoning-effort field: one picker shared by both execution-target editors
3
+ * (the create dialog and the detail page), so the two never describe the same
4
+ * capability differently. The levels are the ones the picked model's catalog
5
+ * entry declares; an empty value is a real choice — "follow the model or Host
6
+ * default" — and is the default state.
7
+ *
8
+ * A stored level the roster no longer lists (the model left the roster, or its
9
+ * adapter changed) stays shown and labelled, but is not offered again, so the
10
+ * field never silently shows a different value than the one the card will ask
11
+ * for while the choosable set stays exactly the levels the model declares.
12
+ */
13
+ import type { ExecutionModelOption } from '../../core/controller.ts'
14
+ import { t } from '../locales.ts'
15
+ import css from '../board.module.css'
16
+ import { reasoningEffortsFor, reasoningHintKey, reasoningPickerEnabled } from './model-options.ts'
17
+
18
+ /** The controlled reasoning-effort picker. */
19
+ export function ReasoningEffortField({ models, modelId, value, pending, onChange }: {
20
+ /** The roster the picked model is looked up in. */
21
+ models: readonly ExecutionModelOption[]
22
+ /** The model this card pins; '' means "no pin". */
23
+ modelId: string
24
+ /** The selected effort; '' means "follow the model or Host default". */
25
+ value: string
26
+ /** Whether a mutation is in flight (the field locks with the rest of the form). */
27
+ pending: boolean
28
+ onChange: (next: string) => void
29
+ }) {
30
+ const efforts = reasoningEffortsFor(models, modelId)
31
+ const unlisted = value !== '' && !efforts.includes(value)
32
+ return (
33
+ <>
34
+ <label className={css.field}>
35
+ <span className={css.fieldLabel}>{t('exec.reasoning')}</span>
36
+ <select
37
+ className={css.select}
38
+ value={value}
39
+ disabled={pending || !reasoningPickerEnabled(models, modelId)}
40
+ onChange={event => { onChange(event.target.value) }}
41
+ >
42
+ <option value="">{t('exec.reasoning.inherit')}</option>
43
+ {unlisted && <option value={value} disabled>{value}{t('exec.reasoning.unknown')}</option>}
44
+ {efforts.map(effort => <option key={effort} value={effort}>{effort}</option>)}
45
+ </select>
46
+ </label>
47
+ <p className={css.detailText}>{t(reasoningHintKey(models, modelId))}</p>
48
+ </>
49
+ )
50
+ }
@@ -12,7 +12,7 @@ import { isValidCron } from '../../core/schedule.ts'
12
12
  import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type ScheduleMode, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
13
13
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
14
14
  import { requiresPermissionConfirmation } from '../../core/handover.ts'
15
- import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
15
+ import { DEFAULT_SUBTASK_DEPTH, directSubtasks, joinsCascadeRun, taskDepth } from '../../core/subtask.ts'
16
16
  import { t, type TaskBoardKey } from '../locales.ts'
17
17
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
18
18
  import { budgetLabel, defaultZonedInputValue, endedLabel, nextRunLabel, parseZonedInput, runCapOption, skippedLabel, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
@@ -24,6 +24,8 @@ import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
24
24
  import { NewTaskModal } from './NewTaskModal.tsx'
25
25
  import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
26
26
  import { inheritPresetLabel, presetLabel } from './preset-label.ts'
27
+ import { ReasoningEffortField } from './ReasoningEffortField.tsx'
28
+ import { reconcileReasoningEffort } from './model-options.ts'
27
29
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
28
30
  import { STATUS_KEY } from './status-key.ts'
29
31
  import { VerificationReport } from './VerificationReport.tsx'
@@ -98,16 +100,18 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
98
100
  goalRunEnabled: boolean
99
101
  }) {
100
102
  const options = executionOptions
103
+ const models = options.models ?? []
101
104
  const workspaceId = task.workspaceId ?? ''
102
105
  const mode = task.mode ?? ''
103
106
  const permission = task.permission ?? ''
104
107
  const model = task.model ?? ''
108
+ const reasoningEffort = task.reasoningEffort ?? ''
105
109
  // A pinned target may disappear from the runtime (workspace deleted,
106
110
  // preset removed); keep it selectable as a stale row instead of silently
107
111
  // dropping it, so the user sees exactly what the task will ask for.
108
112
  const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
109
113
  const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
110
- const modelKnown = model === '' || (options.models ?? []).some(item => item.id === model)
114
+ const modelKnown = model === '' || models.some(item => item.id === model)
111
115
  return (
112
116
  <section className={css.detailSection}>
113
117
  <h4>{t('detail.executionSettings')}</h4>
@@ -166,15 +170,35 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
166
170
  className={css.select}
167
171
  value={model}
168
172
  disabled={pending}
169
- onChange={event => { controller.updateTask(task.id, { model: event.target.value === '' ? undefined : event.target.value }) }}
173
+ onChange={event => {
174
+ // The effort is part of this pin: switching to a model that cannot
175
+ // honor the stored level clears it in the same action instead of
176
+ // leaving a value the next run would have to reject. The empty
177
+ // string carries a clear across the JSON transport, where an
178
+ // `undefined` value would lose the key entirely; the ledger
179
+ // normalizes blank targets back to "pinned nothing".
180
+ const next = event.target.value
181
+ const effort = reconcileReasoningEffort(models, next, reasoningEffort)
182
+ controller.updateTask(task.id, {
183
+ model: next,
184
+ ...(effort === reasoningEffort ? {} : { reasoningEffort: effort }),
185
+ })
186
+ }}
170
187
  >
171
188
  <option value="">{t('exec.model.default')}</option>
172
189
  {!modelKnown && <option value={model}>{model}{t('exec.model.unknown')}</option>}
173
- {options.models?.map(item => (
190
+ {models.map(item => (
174
191
  <option key={item.id} value={item.id}>{item.name ?? item.id}</option>
175
192
  ))}
176
193
  </select>
177
194
  </label>
195
+ <ReasoningEffortField
196
+ models={models}
197
+ modelId={model}
198
+ value={reasoningEffort}
199
+ pending={pending}
200
+ onChange={effort => { controller.updateTask(task.id, { reasoningEffort: effort }) }}
201
+ />
178
202
  <label className={css.scheduleToggle}>
179
203
  <input
180
204
  type="checkbox"
@@ -550,6 +574,18 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
550
574
  )
551
575
  }
552
576
 
577
+ /**
578
+ * The run hint of one card: how many subtasks a run opens, counting the same
579
+ * members the Host will launch (`joinsCascadeRun` leaves a finished subtask
580
+ * alone), or why it opens none even though the card has subtasks.
581
+ */
582
+ function subtaskRunHint(parentId: string, children: readonly TaskRecord[]): string {
583
+ const runnable = children.filter(child => joinsCascadeRun(child, parentId))
584
+ return runnable.length > 0
585
+ ? t('detail.subtasks.runHint', { count: String(runnable.length) })
586
+ : t('detail.subtasks.runHintSettled')
587
+ }
588
+
553
589
  /**
554
590
  * The subtask block: the parent link, the direct subtasks with their status,
555
591
  * and the actions that grow or prune the tree. Every gate here mirrors the
@@ -620,7 +656,7 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
620
656
  </ul>
621
657
  )}
622
658
  {children.length > 0 && (
623
- <p className={css.detailText}>{t('detail.subtasks.runHint', { count: String(children.length) })}</p>
659
+ <p className={css.detailText}>{subtaskRunHint(task.id, children)}</p>
624
660
  )}
625
661
  {!archived && (canAddChild
626
662
  ? (controller.isHostBacked()
@@ -894,7 +930,7 @@ export function TaskDetail({ controller, task, phase = 'open' }: { controller: B
894
930
  className={css.primaryButton}
895
931
  disabled={busy || pending}
896
932
  title={subtaskChildren.length > 0
897
- ? t('detail.subtasks.runHint', { count: String(subtaskChildren.length) })
933
+ ? subtaskRunHint(current.id, subtaskChildren)
898
934
  : undefined}
899
935
  onClick={() => {
900
936
  void controller.rerunTask(current.id).then(() => {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Execution model options: the picker rows built from the official
3
+ * `session/modelCatalog` reply, and the reasoning-effort derivation both
4
+ * execution-target editors read off them (the create dialog and the detail
5
+ * page share one contract here, so they cannot promise different levels).
6
+ *
7
+ * The pickers must offer each model's real reasoning levels, which only the
8
+ * catalog declares — so the reply is normalized once, here, instead of every
9
+ * picker re-reading the wire shape. The fallback discovery path carries no
10
+ * capability metadata at all; its rows stay silent about reasoning rather than
11
+ * claiming a model supports none.
12
+ */
13
+ import type { ExecutionModelOption } from '../../core/controller.ts'
14
+ import type { TaskBoardKey } from '../locales.ts'
15
+
16
+ /** One model's declared reasoning capability, as a picker row carries it. */
17
+ type ModelReasoning = NonNullable<ExecutionModelOption['reasoning']>
18
+
19
+ /** A plain JSON object, or undefined for anything else (including arrays). */
20
+ function record(value: unknown): Record<string, unknown> | undefined {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
22
+ }
23
+
24
+ /** A non-empty string, or undefined. */
25
+ function text(value: unknown): string | undefined {
26
+ return typeof value === 'string' && value !== '' ? value : undefined
27
+ }
28
+
29
+ /** The effort ids an adapter published for one model, in catalog order. */
30
+ function effortIdsOf(reasoning: Record<string, unknown>): string[] {
31
+ const efforts: string[] = []
32
+ for (const entry of Array.isArray(reasoning.efforts) ? reasoning.efforts : []) {
33
+ const id = text(record(entry)?.id)
34
+ if (id !== undefined && !efforts.includes(id)) efforts.push(id)
35
+ }
36
+ return efforts
37
+ }
38
+
39
+ /**
40
+ * What the catalog declares about one model's reasoning: a published
41
+ * `reasoning` block is what "this route reasons" means, and a model published
42
+ * without one is declared to have none. A model the reply never described at
43
+ * all is a different state, expressed by the row carrying no capability.
44
+ */
45
+ function reasoningOf(model: Record<string, unknown>): ModelReasoning {
46
+ const reasoning = record(model.reasoning)
47
+ if (reasoning === undefined) return { supported: false }
48
+ const efforts = effortIdsOf(reasoning)
49
+ return { supported: true, ...(efforts.length === 0 ? {} : { efforts }) }
50
+ }
51
+
52
+ /**
53
+ * One picker row, or undefined when the entry carries no usable id.
54
+ * @param withCapability - whether this reply declares reasoning metadata at
55
+ * all; the flat discovery list does not, so its rows stay silent about it.
56
+ */
57
+ function rowOf(model: Record<string, unknown>, provider: string | undefined, withCapability: boolean): ExecutionModelOption | undefined {
58
+ const id = text(model.id)
59
+ if (id === undefined) return undefined
60
+ const reasoning = withCapability ? reasoningOf(model) : undefined
61
+ return {
62
+ id: provider === undefined ? id : `${provider}/${id}`,
63
+ name: text(model.name) ?? id,
64
+ ...(provider === undefined ? {} : { provider }),
65
+ ...(reasoning === undefined ? {} : { reasoning }),
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Normalize a `session/modelCatalog` reply into picker rows: one row per model
71
+ * of every provider group, keyed by the qualified route an execution pins.
72
+ * @param value - the raw catalog reply.
73
+ * @returns the rows, empty when the reply is unreadable.
74
+ */
75
+ export function modelOptionsFromCatalog(value: unknown): ExecutionModelOption[] {
76
+ const options: ExecutionModelOption[] = []
77
+ const groups = record(value)?.groups
78
+ for (const entry of Array.isArray(groups) ? groups : []) {
79
+ const group = record(entry)
80
+ if (group === undefined) continue
81
+ const provider = text(group.id) ?? text(group.provider)
82
+ for (const item of Array.isArray(group.models) ? group.models : []) {
83
+ const model = record(item)
84
+ if (model === undefined) continue
85
+ const row = rowOf(model, provider, true)
86
+ if (row !== undefined) options.push(row)
87
+ }
88
+ }
89
+ return options
90
+ }
91
+
92
+ /**
93
+ * Normalize the flat `llm/discoverModels` list into picker rows. That reply
94
+ * names models only, so every row leaves the reasoning capability unknown.
95
+ * @param value - the raw discovery list.
96
+ * @returns the rows, empty when the list is unreadable.
97
+ */
98
+ export function modelOptionsFromDiscoveredModels(value: unknown): ExecutionModelOption[] {
99
+ const options: ExecutionModelOption[] = []
100
+ for (const item of Array.isArray(value) ? value : []) {
101
+ const model = record(item)
102
+ if (model === undefined) continue
103
+ const row = rowOf(model, undefined, false)
104
+ if (row !== undefined) options.push(row)
105
+ }
106
+ return options
107
+ }
108
+
109
+ /**
110
+ * The picker rows of a model roster: the qualified `provider/model` route as
111
+ * the value a pin stores, and the provider beside the model name so two
112
+ * providers offering one model name stay distinguishable.
113
+ * @param models - the roster rows.
114
+ * @returns the choice rows, in roster order.
115
+ */
116
+ export function modelChoiceRows(models: readonly ExecutionModelOption[]): Array<{ value: string; label: string }> {
117
+ return models.map(model => ({
118
+ value: model.id,
119
+ label: (model.provider === undefined ? '' : model.provider + ' · ') + (model.name ?? model.id),
120
+ }))
121
+ }
122
+
123
+ /**
124
+ * The capability row the roster publishes for one model route.
125
+ * @returns undefined when no model is pinned, or the roster does not describe
126
+ * the pinned one — an unknown model is not the same as a model without
127
+ * reasoning.
128
+ */
129
+ export function reasoningOfModel(
130
+ models: readonly ExecutionModelOption[],
131
+ modelId: string,
132
+ ): ExecutionModelOption['reasoning'] | undefined {
133
+ if (modelId === '') return undefined
134
+ return models.find(item => item.id === modelId)?.reasoning
135
+ }
136
+
137
+ /**
138
+ * The effort ids the picker offers for a model, in catalog order.
139
+ * @returns an empty list when the model is unpinned, unknown, declares no
140
+ * reasoning, or publishes no selectable level.
141
+ */
142
+ export function reasoningEffortsFor(models: readonly ExecutionModelOption[], modelId: string): string[] {
143
+ const reasoning = reasoningOfModel(models, modelId)
144
+ return reasoning?.supported === true ? [...reasoning.efforts ?? []] : []
145
+ }
146
+
147
+ /** Whether the effort picker may be operated: a pinned model with declared, selectable levels. */
148
+ export function reasoningPickerEnabled(models: readonly ExecutionModelOption[], modelId: string): boolean {
149
+ return reasoningEffortsFor(models, modelId).length > 0
150
+ }
151
+
152
+ /**
153
+ * The effort to keep when the picked model changes. Only a model that declares
154
+ * its levels can invalidate one: a model the roster cannot describe keeps the
155
+ * stored value, because the roster has no grounds to call it invalid.
156
+ * @param effort - the effort currently stored/selected ('' = follow the default).
157
+ * @returns the effort to keep.
158
+ */
159
+ export function reconcileReasoningEffort(
160
+ models: readonly ExecutionModelOption[],
161
+ modelId: string,
162
+ effort: string,
163
+ ): string {
164
+ if (effort === '') return ''
165
+ const reasoning = reasoningOfModel(models, modelId)
166
+ if (reasoning === undefined) return modelId === '' ? '' : effort
167
+ return reasoning.efforts?.includes(effort) === true ? effort : ''
168
+ }
169
+
170
+ /**
171
+ * The copy explaining why the picker is in its current state. One lookup for
172
+ * both editors, so the create dialog and the detail page never drift apart.
173
+ */
174
+ export function reasoningHintKey(models: readonly ExecutionModelOption[], modelId: string): TaskBoardKey {
175
+ if (modelId === '') return 'exec.reasoning.hint.pickModel'
176
+ if (reasoningOfModel(models, modelId) === undefined) return 'exec.reasoning.hint.unknownModel'
177
+ return reasoningPickerEnabled(models, modelId) ? 'exec.reasoning.hint.inherit' : 'exec.reasoning.hint.noEfforts'
178
+ }
@@ -42,3 +42,35 @@ export function inheritPresetLabel(presets: readonly ExecutionPresetOption[]): s
42
42
  if (fallback === undefined) return t('exec.mode.inherit')
43
43
  return t('exec.mode.inheritWith', { preset: presetLabel(fallback) })
44
44
  }
45
+
46
+ /** One preset row a picker renders: the stored id, its label, and whether it may be picked. */
47
+ export interface PresetChoiceRow {
48
+ /** The value a pin stores. */
49
+ id: string
50
+ /** Display label, already carrying the default and unavailable suffixes. */
51
+ label: string
52
+ /** True when the preset cannot compose a session: the row is shown but not selectable. */
53
+ disabled: boolean
54
+ }
55
+
56
+ /**
57
+ * The picker rows of one roster, split into the built-in and the user presets.
58
+ * Both pickers render these rows — the create dialog's mode select and the
59
+ * settings card's default-preset field — so their wording and their
60
+ * unavailable rows cannot drift apart.
61
+ * @param presets - the roster options.
62
+ * @returns one row list per group, in roster order.
63
+ */
64
+ export function presetChoiceRows(presets: readonly ExecutionPresetOption[]): { builtin: PresetChoiceRow[]; custom: PresetChoiceRow[] } {
65
+ const row = (preset: ExecutionPresetOption): PresetChoiceRow => ({
66
+ id: preset.id,
67
+ label: presetLabel(preset)
68
+ + (preset.isDefault ? t('exec.mode.defaultSuffix') : '')
69
+ + (preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''),
70
+ disabled: preset.broken !== undefined,
71
+ })
72
+ return {
73
+ builtin: presets.filter(preset => isBuiltinPreset(preset.id)).map(row),
74
+ custom: presets.filter(preset => !isBuiltinPreset(preset.id)).map(row),
75
+ }
76
+ }