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

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -4,14 +4,19 @@
4
4
  * the Web UI plugin group renders, bound to the `task-board` namespace.
5
5
  */
6
6
 
7
- import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
7
+ import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
8
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
9
9
  import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
10
10
  import { useEffect, useState } from 'react'
11
- import type { TaskBoardPowerSnapshot } from '../protocol.ts'
12
- import { PluginSettingsCard, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
11
+ import type { TaskBoardPowerSnapshot, TaskBoardVerificationOptions } from '../protocol.ts'
12
+ import type { TaskBoardExtensionDispatch } from '../core/extension.ts'
13
+ import { resolveContract, type ModelCatalogView, type VerificationSettings } from '../core/verification.ts'
14
+ import { parseModelRoute } from '../core/verification.ts'
15
+ import { PluginSettingsCard, BooleanField, ChoiceField, ValueField } from './PluginSettingsCard.tsx'
13
16
  import { SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from '../core/subtask.ts'
17
+ import { SESSION_POLL_MAX_SECONDS, SESSION_POLL_MIN_SECONDS } from '../core/poll-cadence.ts'
14
18
  import { CardForm, booleanField, type CardActions, type CardShell, type FieldSpec, type FieldState as CardFieldState } from './settings-form.ts'
19
+ import settingsCss from './board-settings.module.css'
15
20
 
16
21
  /** The depth choices the card offers, derived from the supported range. */
17
22
  const SUBTASK_DEPTH_CHOICES: readonly string[] = Array.from(
@@ -19,6 +24,28 @@ const SUBTASK_DEPTH_CHOICES: readonly string[] = Array.from(
19
24
  (_, index) => String(SUBTASK_DEPTH_MIN + index),
20
25
  )
21
26
 
27
+ /**
28
+ * The roster-poll cadence field: a whole number of seconds inside the
29
+ * supported range, because the Host schema (`sessionPollSeconds`) is numeric.
30
+ * A draft outside the range blocks the save instead of staging a value the
31
+ * Host would clamp behind the user's back.
32
+ */
33
+ function sessionPollField(): FieldSpec {
34
+ return {
35
+ field: 'sessionPollSeconds',
36
+ format: value => typeof value === 'number' && Number.isInteger(value) ? String(value) : '',
37
+ parse: (text) => {
38
+ const trimmed = text.trim()
39
+ if (trimmed === '') return { kind: 'clear' }
40
+ if (!/^\d+$/.test(trimmed)) return undefined
41
+ const seconds = Number(trimmed)
42
+ return seconds >= SESSION_POLL_MIN_SECONDS && seconds <= SESSION_POLL_MAX_SECONDS
43
+ ? { kind: 'set', value: seconds }
44
+ : undefined
45
+ },
46
+ }
47
+ }
48
+
22
49
  /**
23
50
  * The depth field: a choice among the supported levels whose draft text is a
24
51
  * number, because the Host schema (`maxSubtaskDepth`) is numeric. A draft
@@ -37,6 +64,39 @@ function subtaskDepthField(): FieldSpec {
37
64
  }
38
65
  }
39
66
 
67
+ /**
68
+ * The judge-model field: blank inherits the host model catalog default, and a
69
+ * non-blank draft must be a qualified provider/model route. An unparseable
70
+ * draft blocks the save instead of storing a route nothing can resolve.
71
+ */
72
+ function judgeModelField(): FieldSpec {
73
+ return {
74
+ field: 'goalVerificationModel',
75
+ format: value => typeof value === 'string' ? value : '',
76
+ parse: (text) => {
77
+ const trimmed = text.trim()
78
+ if (trimmed === '') return { kind: 'clear' }
79
+ return parseModelRoute(trimmed) === undefined ? undefined : { kind: 'set', value: trimmed }
80
+ },
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The reasoning-effort field. Any non-blank id is staged: which levels a model
86
+ * accepts is decided by the host catalog, and the settings card shows the
87
+ * resolved level (including a fallback) rather than guessing here.
88
+ */
89
+ function judgeEffortField(): FieldSpec {
90
+ return {
91
+ field: 'goalVerificationReasoningEffort',
92
+ format: value => typeof value === 'string' ? value : '',
93
+ parse: (text) => {
94
+ const trimmed = text.trim()
95
+ return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
96
+ },
97
+ }
98
+ }
99
+
40
100
  /** The task-board fields this card edits (the namespace's full schema). */
41
101
  export interface TaskBoardSettings {
42
102
  /** Master switch for the plugin. */
@@ -47,6 +107,20 @@ export interface TaskBoardSettings {
47
107
  preventIdleSleep?: boolean
48
108
  /** Subtask depth limit (1..3); 1 means a single level of subtasks. */
49
109
  maxSubtaskDepth?: number
110
+ /** Roster-poll cadence in seconds while the board has work to reconcile. */
111
+ sessionPollSeconds?: number
112
+ /**
113
+ * GLOBAL native /goal switch (default off): the master control over whether
114
+ * this board starts a run with dsh's built-in /goal at all. Frozen per
115
+ * execution.
116
+ */
117
+ goalRunEnabled?: boolean
118
+ /** Goal acceptance for this board's executions (default on). */
119
+ goalVerification?: boolean
120
+ /** Judge model route for goal acceptance; blank inherits the host default. */
121
+ goalVerificationModel?: string
122
+ /** Judge reasoning effort; blank inherits the host default level. */
123
+ goalVerificationReasoningEffort?: string
50
124
  }
51
125
 
52
126
  /** What the task-board card renders. */
@@ -59,6 +133,16 @@ export interface TaskBoardSettingsCardState extends CardShell {
59
133
  preventIdleSleep: CardFieldState
60
134
  /** Subtask depth limit field. */
61
135
  maxSubtaskDepth: CardFieldState
136
+ /** Roster-poll cadence field. */
137
+ sessionPollSeconds: CardFieldState
138
+ /** GLOBAL native /goal switch. */
139
+ goalRunEnabled: CardFieldState
140
+ /** Goal-acceptance switch. */
141
+ goalVerification: CardFieldState
142
+ /** Judge model route field. */
143
+ goalVerificationModel: CardFieldState
144
+ /** Judge reasoning-effort field. */
145
+ goalVerificationReasoningEffort: CardFieldState
62
146
  }
63
147
 
64
148
  /** The registration-side face the card's slot entry injects. */
@@ -67,6 +151,8 @@ export interface TaskBoardSettingsCardFace extends CardActions {
67
151
  /** Card snapshot bound by the renderer as useTaskBoardSettingsCard. */
68
152
  taskBoardSettingsCard: SnapshotStore<TaskBoardSettingsCardState>
69
153
  }
154
+ /** Deliver one provider action over the board's same-origin channel. */
155
+ dispatch: TaskBoardExtensionDispatch
70
156
  }
71
157
 
72
158
  /** Bridges the `task-board` settings form onto the card's staged form. */
@@ -75,12 +161,17 @@ export class TaskBoardSettingsCardController {
75
161
  private readonly store: SnapshotStore<TaskBoardSettingsCardState>
76
162
 
77
163
  /** @param scope - the bound configuration form for the `task-board` namespace. */
78
- constructor(scope: ConfigForm<TaskBoardSettings>) {
164
+ constructor(scope: ConfigForm<TaskBoardSettings>, private readonly dispatch: TaskBoardExtensionDispatch) {
79
165
  this.form = new CardForm(scope, [
80
166
  booleanField('enabled'),
81
167
  booleanField('announceToAgent'),
82
168
  booleanField('preventIdleSleep'),
83
169
  subtaskDepthField(),
170
+ sessionPollField(),
171
+ booleanField('goalRunEnabled'),
172
+ booleanField('goalVerification'),
173
+ judgeModelField(),
174
+ judgeEffortField(),
84
175
  ])
85
176
  this.store = this.form.bind(() => this.projection())
86
177
  }
@@ -92,6 +183,11 @@ export class TaskBoardSettingsCardController {
92
183
  announceToAgent: this.form.field('announceToAgent'),
93
184
  preventIdleSleep: this.form.field('preventIdleSleep'),
94
185
  maxSubtaskDepth: this.form.field('maxSubtaskDepth'),
186
+ sessionPollSeconds: this.form.field('sessionPollSeconds'),
187
+ goalRunEnabled: this.form.field('goalRunEnabled'),
188
+ goalVerification: this.form.field('goalVerification'),
189
+ goalVerificationModel: this.form.field('goalVerificationModel'),
190
+ goalVerificationReasoningEffort: this.form.field('goalVerificationReasoningEffort'),
95
191
  }
96
192
  }
97
193
 
@@ -100,7 +196,7 @@ export class TaskBoardSettingsCardController {
100
196
  * @returns the card's snapshot and its form actions.
101
197
  */
102
198
  inject(): TaskBoardSettingsCardFace {
103
- return { hooks: { taskBoardSettingsCard: this.store }, ...this.form.actions() }
199
+ return { hooks: { taskBoardSettingsCard: this.store }, ...this.form.actions(), dispatch: this.dispatch }
104
200
  }
105
201
 
106
202
  /**
@@ -117,6 +213,18 @@ export type TaskBoardSettingsCardProps =
117
213
  PropsRuntime<'web-ui.plugin.item'>
118
214
  & PropsLocale<'task-board'>
119
215
  & InjectFace<TaskBoardSettingsCardFace>
216
+ & PropsRenderSlots<'task-board.settings.section'>
217
+
218
+ /** Kanban mark for the card header: three columns of unequal depth. */
219
+ function BoardMark() {
220
+ return (
221
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
222
+ <rect x="2.2" y="2.6" width="3.9" height="12.8" rx="1.95" fill="currentColor" />
223
+ <rect x="7.05" y="2.6" width="3.9" height="7.6" rx="1.95" fill="currentColor" />
224
+ <rect x="11.9" y="2.6" width="3.9" height="10.2" rx="1.95" fill="currentColor" />
225
+ </svg>
226
+ )
227
+ }
120
228
 
121
229
  /**
122
230
  * Render the task-board card.
@@ -124,15 +232,22 @@ export type TaskBoardSettingsCardProps =
124
232
  * @returns the card.
125
233
  */
126
234
  export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
127
- const { t } = props
235
+ const { t, renderSlot, dispatch } = props
128
236
  const state = props.useTaskBoardSettingsCard(snapshot => snapshot)
129
237
  const disabled = !state.writable
130
238
  const [power, setPower] = useState<TaskBoardPowerSnapshot | undefined>()
239
+ const [verification, setVerification] = useState<TaskBoardVerificationOptions | undefined>()
131
240
  useEffect(() => {
132
241
  // The SSE channel already carries power on every real change and pushes
133
242
  // one frame on subscribe; polling the full /state snapshot every 5 s
134
243
  // re-cloned and re-serialized the whole ledger server-side for one field.
135
244
  let live = true
245
+ void fetch('api/task-board/verification')
246
+ .then(r => r.ok ? r.json() : undefined)
247
+ .then((data: TaskBoardVerificationOptions | undefined) => {
248
+ if (data?.catalog && live) setVerification(data)
249
+ })
250
+ .catch(() => {})
136
251
  const events = new EventSource('api/task-board/events')
137
252
  events.onmessage = (message: MessageEvent<string>): void => {
138
253
  try {
@@ -144,17 +259,69 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
144
259
  }
145
260
  return () => { live = false; events.close() }
146
261
  }, [])
262
+ // The staged drafts drive the resolved preview, so an unsaved model or level
263
+ // change already shows what the next execution would freeze (including a
264
+ // reasoning-level fallback the target model forces).
265
+ const catalog: ModelCatalogView = verification?.catalog ?? { groups: [] }
266
+ const stagedSettings: VerificationSettings = {
267
+ enabled: state.goalVerification.text !== 'false',
268
+ model: state.goalVerificationModel.text,
269
+ reasoningEffort: state.goalVerificationReasoningEffort.text,
270
+ }
271
+ const preview = resolveContract(stagedSettings, catalog)
272
+ const inheritRoute = catalog.default
273
+ const modelChoices = [
274
+ {
275
+ value: '',
276
+ label: inheritRoute === undefined
277
+ ? t('settings.goalVerificationModelInheritUnknown')
278
+ : t('settings.goalVerificationModelInherit', { model: inheritRoute.provider + '/' + inheritRoute.model }),
279
+ },
280
+ ...catalog.groups.flatMap(group => group.models.map(model => ({
281
+ value: group.id + '/' + model.id,
282
+ label: (group.name ?? group.id) + ' · ' + (model.name ?? model.id),
283
+ }))),
284
+ ]
285
+ const stagedRoute = parseModelRoute(stagedSettings.model) ?? (inheritRoute === undefined ? undefined : { provider: inheritRoute.provider, model: inheritRoute.model })
286
+ const stagedEfforts = stagedRoute === undefined
287
+ ? []
288
+ : catalog.groups.find(group => group.id === stagedRoute.provider)?.models.find(model => model.id === stagedRoute.model)?.reasoning?.efforts ?? []
289
+ const effortChoices = [
290
+ {
291
+ value: '',
292
+ label: stagedRoute === undefined || stagedEfforts.length === 0
293
+ ? t('settings.goalVerificationEffortInheritUnknown')
294
+ : t('settings.goalVerificationEffortInherit', {
295
+ effort: catalog.groups.find(group => group.id === stagedRoute.provider)?.models
296
+ .find(model => model.id === stagedRoute.model)?.reasoning?.defaultEffort ?? t('settings.goalVerificationEffortInheritUnknown'),
297
+ }),
298
+ },
299
+ ...stagedEfforts.map(effort => ({ value: effort.id, label: effort.name ?? effort.id })),
300
+ ]
147
301
  const fieldProps = {
148
302
  overriddenLabel: t('settings.overridden'),
149
303
  resetLabel: t('settings.reset'),
150
304
  invalidLabel: t('settings.invalidNumber'),
151
305
  disabled,
152
306
  }
307
+ // Chrome state of the nested disclosure cards below: they stage into this
308
+ // card's form and are written by its one save, so they carry no footer and no
309
+ // unsaved pill of their own (the outer header owns the pill).
310
+ const nestedShell: CardShell = {
311
+ available: true,
312
+ exposed: true,
313
+ writable: state.writable,
314
+ dirty: false,
315
+ invalid: state.invalid,
316
+ saving: state.saving,
317
+ failed: false,
318
+ }
153
319
  return (
154
320
  <PluginSettingsCard
155
321
  t={t}
156
322
  titleKey="settings.title"
157
323
  descriptionKey="settings.description"
324
+ icon={<BoardMark />}
158
325
  defaultOpen={false}
159
326
  state={state}
160
327
  renderChildrenWhenNotExposed
@@ -162,66 +329,211 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
162
329
  onSave={props.save}
163
330
  onDiscard={props.discard}
164
331
  >
165
- <BooleanField
166
- id="settings-task-board-enabled"
167
- label={t('settings.enabled')}
168
- hint={t('settings.enabledHint')}
169
- inheritLabel={t('settings.inherit')}
170
- onLabel={t('settings.on')}
171
- offLabel={t('settings.off')}
172
- {...fieldProps}
173
- {...state.enabled}
174
- onEdit={(text) => { props.edit('enabled', text) }}
175
- onReset={() => { props.resetField('enabled') }}
176
- />
177
- <BooleanField
178
- id="settings-task-board-announce"
179
- label={t('settings.announceToAgent')}
180
- hint={t('settings.announceToAgentHint')}
181
- inheritLabel={t('settings.inherit')}
182
- onLabel={t('settings.on')}
183
- offLabel={t('settings.off')}
184
- {...fieldProps}
185
- {...state.announceToAgent}
186
- onEdit={(text) => { props.edit('announceToAgent', text) }}
187
- onReset={() => { props.resetField('announceToAgent') }}
188
- />
189
- <BooleanField
190
- id="settings-task-board-prevent-idle-sleep"
191
- label={t('settings.preventIdleSleep')}
192
- hint={t('settings.preventIdleSleepHint')}
193
- inheritLabel={t('settings.inherit')}
194
- onLabel={t('settings.on')}
195
- offLabel={t('settings.off')}
196
- {...fieldProps}
197
- {...state.preventIdleSleep}
198
- onEdit={(text) => { props.edit('preventIdleSleep', text) }}
199
- onReset={() => { props.resetField('preventIdleSleep') }}
200
- />
201
- <ChoiceField
202
- id="settings-task-board-subtask-depth"
203
- label={t('settings.maxSubtaskDepth')}
204
- hint={t('settings.maxSubtaskDepthHint')}
205
- inheritLabel={t('settings.inherit')}
206
- choices={SUBTASK_DEPTH_CHOICES.map(value => ({
207
- value,
208
- label: t('settings.maxSubtaskDepthOption', { depth: value }),
209
- }))}
210
- {...fieldProps}
211
- {...state.maxSubtaskDepth}
212
- onEdit={(text) => { props.edit('maxSubtaskDepth', text) }}
213
- onReset={() => { props.resetField('maxSubtaskDepth') }}
214
- />
215
- <p>
216
- {t('settings.powerStatus', {
217
- platform: power?.platform ?? t('settings.powerUnknown'),
218
- phase: power?.phase ?? t('settings.powerUnknown'),
219
- running: String(power?.runningSessions ?? 0),
220
- schedules: String(power?.armedSchedules ?? 0),
221
- })}
222
- </p>
223
- <p>{t('settings.powerBoundary')}</p>
224
- {power?.lastError !== undefined && <p>{t('settings.powerError', { error: power.lastError })}</p>}
332
+ {/* The board's own settings are nested disclosure cards, so an expanded
333
+ card reads as a short topic list: the board and its runtime behavior,
334
+ goal acceptance, and whatever a provider contributes (a provider
335
+ extension registers into the seat rendered last). Every one
336
+ of them starts collapsed and shares this card's single save. */}
337
+ <ul className={settingsCss.nestedCards}>
338
+ <PluginSettingsCard
339
+ t={t}
340
+ titleKey="settings.enabled"
341
+ descriptionKey="settings.enabledCardHint"
342
+ defaultOpen={false}
343
+ hideFooter
344
+ state={nestedShell}
345
+ onSave={props.save}
346
+ onDiscard={props.discard}
347
+ >
348
+ <BooleanField
349
+ id="settings-task-board-enabled"
350
+ label={t('settings.enabled')}
351
+ hint={t('settings.enabledHint')}
352
+ inheritLabel={t('settings.inherit')}
353
+ onLabel={t('settings.on')}
354
+ offLabel={t('settings.off')}
355
+ {...fieldProps}
356
+ {...state.enabled}
357
+ onEdit={(text) => { props.edit('enabled', text) }}
358
+ onReset={() => { props.resetField('enabled') }}
359
+ />
360
+ <BooleanField
361
+ id="settings-task-board-announce"
362
+ label={t('settings.announceToAgent')}
363
+ hint={t('settings.announceToAgentHint')}
364
+ inheritLabel={t('settings.inherit')}
365
+ onLabel={t('settings.on')}
366
+ offLabel={t('settings.off')}
367
+ {...fieldProps}
368
+ {...state.announceToAgent}
369
+ onEdit={(text) => { props.edit('announceToAgent', text) }}
370
+ onReset={() => { props.resetField('announceToAgent') }}
371
+ />
372
+ <BooleanField
373
+ id="settings-task-board-prevent-idle-sleep"
374
+ label={t('settings.preventIdleSleep')}
375
+ hint={t('settings.preventIdleSleepHint')}
376
+ inheritLabel={t('settings.inherit')}
377
+ onLabel={t('settings.on')}
378
+ offLabel={t('settings.off')}
379
+ {...fieldProps}
380
+ {...state.preventIdleSleep}
381
+ onEdit={(text) => { props.edit('preventIdleSleep', text) }}
382
+ onReset={() => { props.resetField('preventIdleSleep') }}
383
+ />
384
+ <ValueField
385
+ id="settings-task-board-session-poll"
386
+ label={t('settings.sessionPollSeconds')}
387
+ hint={t('settings.sessionPollSecondsHint')}
388
+ numeric
389
+ {...fieldProps}
390
+ {...state.sessionPollSeconds}
391
+ onEdit={(text) => { props.edit('sessionPollSeconds', text) }}
392
+ onReset={() => { props.resetField('sessionPollSeconds') }}
393
+ />
394
+ <ChoiceField
395
+ id="settings-task-board-subtask-depth"
396
+ label={t('settings.maxSubtaskDepth')}
397
+ hint={t('settings.maxSubtaskDepthHint')}
398
+ inheritLabel={t('settings.inherit')}
399
+ choices={SUBTASK_DEPTH_CHOICES.map(value => ({
400
+ value,
401
+ label: t('settings.maxSubtaskDepthOption', { depth: value }),
402
+ }))}
403
+ {...fieldProps}
404
+ {...state.maxSubtaskDepth}
405
+ onEdit={(text) => { props.edit('maxSubtaskDepth', text) }}
406
+ onReset={() => { props.resetField('maxSubtaskDepth') }}
407
+ />
408
+ </PluginSettingsCard>
409
+
410
+ <PluginSettingsCard
411
+ t={t}
412
+ titleKey="settings.runBehaviorTitle"
413
+ descriptionKey="settings.runBehaviorCardHint"
414
+ defaultOpen={false}
415
+ hideFooter
416
+ state={nestedShell}
417
+ onSave={props.save}
418
+ onDiscard={props.discard}
419
+ >
420
+ <BooleanField
421
+ id="settings-task-board-goal-run"
422
+ label={t('settings.goalRunGlobal')}
423
+ hint={t('settings.goalRunGlobalHint')}
424
+ inheritLabel={t('settings.inherit')}
425
+ onLabel={t('settings.on')}
426
+ offLabel={t('settings.off')}
427
+ {...fieldProps}
428
+ {...state.goalRunEnabled}
429
+ onEdit={(text) => { props.edit('goalRunEnabled', text) }}
430
+ onReset={() => { props.resetField('goalRunEnabled') }}
431
+ />
432
+ </PluginSettingsCard>
433
+
434
+ <PluginSettingsCard
435
+ t={t}
436
+ titleKey="settings.goalVerificationTitle"
437
+ descriptionKey="settings.goalVerificationCardHint"
438
+ defaultOpen={false}
439
+ hideFooter
440
+ state={nestedShell}
441
+ onSave={props.save}
442
+ onDiscard={props.discard}
443
+ >
444
+ <BooleanField
445
+ id="settings-task-board-goal-verification"
446
+ label={t('settings.goalVerification')}
447
+ hint={t('settings.goalVerificationHint')}
448
+ inheritLabel={t('settings.inherit')}
449
+ onLabel={t('settings.on')}
450
+ offLabel={t('settings.off')}
451
+ {...fieldProps}
452
+ {...state.goalVerification}
453
+ onEdit={(text) => { props.edit('goalVerification', text) }}
454
+ onReset={() => { props.resetField('goalVerification') }}
455
+ />
456
+ <ChoiceField
457
+ id="settings-task-board-goal-verification-model"
458
+ label={t('settings.goalVerificationModel')}
459
+ hint={t('settings.goalVerificationModelHint')}
460
+ inheritLabel={t('settings.inherit')}
461
+ choices={modelChoices}
462
+ {...fieldProps}
463
+ {...state.goalVerificationModel}
464
+ onEdit={(text) => { props.edit('goalVerificationModel', text) }}
465
+ onReset={() => { props.resetField('goalVerificationModel') }}
466
+ />
467
+ <ChoiceField
468
+ id="settings-task-board-goal-verification-effort"
469
+ label={t('settings.goalVerificationEffort')}
470
+ hint={t('settings.goalVerificationEffortHint')}
471
+ inheritLabel={t('settings.inherit')}
472
+ choices={effortChoices}
473
+ {...fieldProps}
474
+ {...state.goalVerificationReasoningEffort}
475
+ onEdit={(text) => { props.edit('goalVerificationReasoningEffort', text) }}
476
+ onReset={() => { props.resetField('goalVerificationReasoningEffort') }}
477
+ />
478
+ <p className={settingsCss.note}>{t('settings.goalVerificationResolved')}</p>
479
+ {preview.route === undefined
480
+ ? <p className={settingsCss.error}>{t('settings.goalVerificationRouteMissing')}</p>
481
+ : (
482
+ <ul className={settingsCss.resolvedList}>
483
+ <li>{t('settings.goalVerificationResolvedModel', { model: preview.route.provider + '/' + preview.route.model })}</li>
484
+ <li>
485
+ {preview.route.reasoningEffort === undefined
486
+ ? t('settings.goalVerificationResolvedNoEffort')
487
+ : t('settings.goalVerificationResolvedEffort', { effort: preview.route.reasoningEffort })}
488
+ </li>
489
+ <li>
490
+ {t('settings.goalVerificationResolvedSource', {
491
+ source: preview.modelSource === 'inherit'
492
+ ? t('settings.goalVerificationSourceInherit')
493
+ : t('settings.goalVerificationSourceExplicit'),
494
+ })}
495
+ </li>
496
+ <li>{t('settings.goalVerificationResolvedPreset', { threshold: String(preview.threshold) })}</li>
497
+ </ul>
498
+ )}
499
+ {preview.effortFallback !== undefined && (
500
+ <p className={settingsCss.note}>
501
+ {preview.effortFallback.resolved === undefined
502
+ ? t('settings.goalVerificationEffortFallbackNone', { requested: preview.effortFallback.requested })
503
+ : t('settings.goalVerificationEffortFallback', {
504
+ requested: preview.effortFallback.requested,
505
+ resolved: preview.effortFallback.resolved,
506
+ })}
507
+ </p>
508
+ )}
509
+ {stagedSettings.model.trim() !== '' && parseModelRoute(stagedSettings.model) === undefined && (
510
+ <p className={settingsCss.note}>{t('settings.goalVerificationModelInvalid')}</p>
511
+ )}
512
+ </PluginSettingsCard>
513
+
514
+ {/* Provider sections: a family extension contributes its own nested
515
+ settings card into this list through the seat this card declares —
516
+ the family provider extension is the one that does. Each
517
+ contributed card keeps its own chrome and save row, and an empty
518
+ seat draws nothing. */}
519
+ {renderSlot('task-board.settings.section', { dispatch })}
520
+ </ul>
521
+ {/* Live power facts and the battery caveat: small print under the topic
522
+ cards, separated from them, instead of two body-size paragraphs. */}
523
+ <div className={settingsCss.power}>
524
+ <p className={settingsCss.powerLine}>
525
+ {t('settings.powerStatus', {
526
+ platform: power?.platform ?? t('settings.powerUnknown'),
527
+ phase: power?.phase ?? t('settings.powerUnknown'),
528
+ running: String(power?.runningSessions ?? 0),
529
+ schedules: String(power?.armedSchedules ?? 0),
530
+ })}
531
+ </p>
532
+ <p className={settingsCss.powerLine}>{t('settings.powerBoundary')}</p>
533
+ {power?.lastError !== undefined && (
534
+ <p className={settingsCss.powerError}>{t('settings.powerError', { error: power.lastError })}</p>
535
+ )}
536
+ </div>
225
537
  </PluginSettingsCard>
226
538
  )
227
539
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Clamped markdown body for the detail overlay: a long description or prompt
3
+ * renders behind a max-height clamp with a fade into the dialog surface and
4
+ * an expand toggle, so it no longer pushes the execution settings and the
5
+ * execution history out of view. Content that fits the clamp renders
6
+ * untouched — no toggle, no fade, no extra markup behaviour.
7
+ */
8
+ import { useEffect, useRef, useState } from 'react'
9
+ import css from '../board.module.css'
10
+ import { t } from '../locales.ts'
11
+ import { TaskMarkdown } from './task-markdown.tsx'
12
+
13
+ /**
14
+ * Clamp height used when the stylesheet is unavailable to the runtime (unit
15
+ * tests). The rendered value lives on `.clampBody[data-clamped='true']` in
16
+ * board.module.css and is read back through getComputedStyle, so the CSS
17
+ * stays the single source of the geometry.
18
+ */
19
+ const CLAMP_FALLBACK_PX = 160
20
+
21
+ export function ClampedMarkdown({ source }: { source: string }) {
22
+ const [expanded, setExpanded] = useState(false)
23
+ const [overflowing, setOverflowing] = useState(false)
24
+ const bodyRef = useRef<HTMLDivElement | null>(null)
25
+
26
+ // A different text starts clamped again; the measure effect re-decides
27
+ // whether the toggle is needed at all.
28
+ useEffect(() => { setExpanded(false) }, [source])
29
+
30
+ useEffect(() => {
31
+ const node = bodyRef.current
32
+ if (node === null) return
33
+ const measure = (): void => {
34
+ // While clamped the computed max-height is the clamp; while expanded it
35
+ // is 'none' and the fallback keeps the toggle stable. scrollHeight is
36
+ // the full content height in both states (overflow:hidden does not
37
+ // truncate it), so one comparison covers both.
38
+ const declared = Number.parseFloat(getComputedStyle(node).maxHeight)
39
+ const clamp = Number.isFinite(declared) ? declared : CLAMP_FALLBACK_PX
40
+ setOverflowing(node.scrollHeight > clamp + 1)
41
+ }
42
+ measure()
43
+ // The overlay width follows the viewport, so a resize can cross the clamp
44
+ // threshold without the text itself changing.
45
+ if (typeof ResizeObserver === 'undefined') return
46
+ const observer = new ResizeObserver(measure)
47
+ observer.observe(node)
48
+ return () => { observer.disconnect() }
49
+ }, [source, expanded])
50
+
51
+ const clamped = overflowing && !expanded
52
+ return (
53
+ <>
54
+ <div ref={bodyRef} className={css.clampBody} data-clamped={clamped}>
55
+ <TaskMarkdown source={source} />
56
+ </div>
57
+ {overflowing && (
58
+ <button
59
+ type="button"
60
+ className={`${css.linkButton} ${css.clampToggle}`}
61
+ aria-expanded={expanded}
62
+ onClick={() => { setExpanded(!expanded) }}
63
+ >
64
+ {expanded ? t('detail.collapse') : t('detail.expand')}
65
+ </button>
66
+ )}
67
+ </>
68
+ )
69
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { t } from '../locales.ts'
5
5
  import css from '../board.module.css'
6
+ import { useDialog, type OverlayPhase } from './overlay.tsx'
6
7
 
7
8
  /** Confirm overlay props. */
8
9
  export interface ConfirmDialogProps {
@@ -11,15 +12,18 @@ export interface ConfirmDialogProps {
11
12
  confirmLabel: string
12
13
  /** Render the confirm button in the danger style. */
13
14
  danger?: boolean
15
+ /** Which leg of the enter/exit motion pair the surface is on. */
16
+ phase?: OverlayPhase
14
17
  onCancel: () => void
15
18
  onConfirm: () => void
16
19
  }
17
20
 
18
21
  /** Small confirm overlay. */
19
- export function ConfirmDialog({ title, message, confirmLabel, danger, onCancel, onConfirm }: ConfirmDialogProps) {
22
+ export function ConfirmDialog({ title, message, confirmLabel, danger, phase = 'open', onCancel, onConfirm }: ConfirmDialogProps) {
23
+ const dialog = useDialog<HTMLDivElement>(onCancel, phase)
20
24
  return (
21
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onCancel() }}>
22
- <div className={css.modal} role="alertdialog" aria-label={title}>
25
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
26
+ <div ref={dialog.attach} className={css.modal} role="alertdialog" aria-modal="true" aria-label={title} tabIndex={-1}>
23
27
  <h2 className={css.modalTitle}>{title}</h2>
24
28
  <p className={css.confirmMessage}>{message}</p>
25
29
  <footer className={css.modalFooter}>