@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.
- package/README.i18n.yaml +2 -2
- package/README.md +14 -6
- package/README.zh.md +14 -6
- package/lib/client.js +1502 -820
- package/lib/client.js.map +1 -1
- package/lib/index.js +674 -75
- package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +49 -0
- package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
- package/lib/types/client/board/model-options.d.ts +67 -0
- package/lib/types/client/board/preset-label.d.ts +21 -0
- package/lib/types/client/board/preset-roster.d.ts +45 -0
- package/lib/types/client/board/team-run-promotion.d.ts +26 -0
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/core/card-defaults.d.ts +78 -0
- package/lib/types/core/controller.d.ts +32 -7
- package/lib/types/core/launch-width.d.ts +31 -0
- package/lib/types/core/reasoning-effort.d.ts +57 -0
- package/lib/types/core/subtask.d.ts +30 -8
- package/lib/types/core/tasks.d.ts +19 -0
- package/lib/types/core/use-cases/task-create.d.ts +10 -5
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/launch-admission.d.ts +72 -0
- package/lib/types/host-ledger.d.ts +9 -4
- package/lib/types/host-runner.d.ts +79 -4
- package/lib/types/host-service.d.ts +85 -2
- package/lib/types/index.d.ts +87 -0
- package/lib/types/protocol.d.ts +13 -0
- package/package.json +28 -28
- package/src/client/CardDefaultPresetField.tsx +101 -0
- package/src/client/TaskBoardSettingsCard.tsx +214 -16
- package/src/client/board/LinkSubtaskModal.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +86 -40
- package/src/client/board/ReasoningEffortField.tsx +50 -0
- package/src/client/board/TaskDetail.tsx +42 -6
- package/src/client/board/model-options.ts +178 -0
- package/src/client/board/preset-label.ts +32 -0
- package/src/client/board/preset-roster.ts +58 -0
- package/src/client/board/team-run-promotion.ts +39 -0
- package/src/client/index.ts +98 -94
- package/src/client/locales.ts +34 -2
- package/src/client/settings-entry-form.ts +0 -1
- package/src/core/card-defaults.ts +132 -0
- package/src/core/controller.ts +44 -9
- package/src/core/launch-width.ts +38 -0
- package/src/core/reasoning-effort.ts +92 -0
- package/src/core/store.ts +5 -0
- package/src/core/subtask.ts +48 -13
- package/src/core/tasks.ts +20 -0
- package/src/core/use-cases/task-create.ts +27 -16
- package/src/core/use-cases/task-update.ts +6 -1
- package/src/host/agent-tools.ts +21 -8
- package/src/host/launch-admission.ts +140 -0
- package/src/host-ledger.ts +18 -6
- package/src/host-runner.ts +184 -18
- package/src/host-service.ts +247 -23
- package/src/index.ts +149 -2
- 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,
|
|
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
|
|
52
|
-
// inherit the parent's binding together with its human
|
|
53
|
-
// picking a value pins this card's own binding and
|
|
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(
|
|
56
|
-
const [permission, setPermission] = useState(
|
|
57
|
-
const [model, setModel] = useState(
|
|
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
|
|
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 === '' ||
|
|
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 === '' ||
|
|
128
|
-
if (
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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)
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
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 === '' ? [] : [
|
|
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
|
-
{
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
{
|
|
503
|
+
{presetRows.builtin.length > 0 && (
|
|
464
504
|
<optgroup label={t('exec.mode.builtinGroup')}>
|
|
465
|
-
{
|
|
466
|
-
<option key={
|
|
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
|
-
{
|
|
510
|
+
{presetRows.custom.length > 0 && (
|
|
475
511
|
<optgroup label={t('exec.mode.customGroup')}>
|
|
476
|
-
{
|
|
477
|
-
<option key={
|
|
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 => {
|
|
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
|
-
{
|
|
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 === '' ||
|
|
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 => {
|
|
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
|
-
{
|
|
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}>{
|
|
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
|
-
?
|
|
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
|
+
}
|