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

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 (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -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/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
@@ -4,14 +4,18 @@
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'
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'
12
15
  import { PluginSettingsCard, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
13
16
  import { SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from '../core/subtask.ts'
14
17
  import { CardForm, booleanField, type CardActions, type CardShell, type FieldSpec, type FieldState as CardFieldState } from './settings-form.ts'
18
+ import settingsCss from './board-settings.module.css'
15
19
 
16
20
  /** The depth choices the card offers, derived from the supported range. */
17
21
  const SUBTASK_DEPTH_CHOICES: readonly string[] = Array.from(
@@ -37,6 +41,39 @@ function subtaskDepthField(): FieldSpec {
37
41
  }
38
42
  }
39
43
 
44
+ /**
45
+ * The judge-model field: blank inherits the host model catalog default, and a
46
+ * non-blank draft must be a qualified provider/model route. An unparseable
47
+ * draft blocks the save instead of storing a route nothing can resolve.
48
+ */
49
+ function judgeModelField(): FieldSpec {
50
+ return {
51
+ field: 'goalVerificationModel',
52
+ format: value => typeof value === 'string' ? value : '',
53
+ parse: (text) => {
54
+ const trimmed = text.trim()
55
+ if (trimmed === '') return { kind: 'clear' }
56
+ return parseModelRoute(trimmed) === undefined ? undefined : { kind: 'set', value: trimmed }
57
+ },
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The reasoning-effort field. Any non-blank id is staged: which levels a model
63
+ * accepts is decided by the host catalog, and the settings card shows the
64
+ * resolved level (including a fallback) rather than guessing here.
65
+ */
66
+ function judgeEffortField(): FieldSpec {
67
+ return {
68
+ field: 'goalVerificationReasoningEffort',
69
+ format: value => typeof value === 'string' ? value : '',
70
+ parse: (text) => {
71
+ const trimmed = text.trim()
72
+ return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
73
+ },
74
+ }
75
+ }
76
+
40
77
  /** The task-board fields this card edits (the namespace's full schema). */
41
78
  export interface TaskBoardSettings {
42
79
  /** Master switch for the plugin. */
@@ -47,6 +84,12 @@ export interface TaskBoardSettings {
47
84
  preventIdleSleep?: boolean
48
85
  /** Subtask depth limit (1..3); 1 means a single level of subtasks. */
49
86
  maxSubtaskDepth?: number
87
+ /** Goal acceptance for this board's executions (default on). */
88
+ goalVerification?: boolean
89
+ /** Judge model route for goal acceptance; blank inherits the host default. */
90
+ goalVerificationModel?: string
91
+ /** Judge reasoning effort; blank inherits the host default level. */
92
+ goalVerificationReasoningEffort?: string
50
93
  }
51
94
 
52
95
  /** What the task-board card renders. */
@@ -59,6 +102,12 @@ export interface TaskBoardSettingsCardState extends CardShell {
59
102
  preventIdleSleep: CardFieldState
60
103
  /** Subtask depth limit field. */
61
104
  maxSubtaskDepth: CardFieldState
105
+ /** Goal-acceptance switch. */
106
+ goalVerification: CardFieldState
107
+ /** Judge model route field. */
108
+ goalVerificationModel: CardFieldState
109
+ /** Judge reasoning-effort field. */
110
+ goalVerificationReasoningEffort: CardFieldState
62
111
  }
63
112
 
64
113
  /** The registration-side face the card's slot entry injects. */
@@ -67,6 +116,8 @@ export interface TaskBoardSettingsCardFace extends CardActions {
67
116
  /** Card snapshot bound by the renderer as useTaskBoardSettingsCard. */
68
117
  taskBoardSettingsCard: SnapshotStore<TaskBoardSettingsCardState>
69
118
  }
119
+ /** Deliver one provider action over the board's same-origin channel. */
120
+ dispatch: TaskBoardExtensionDispatch
70
121
  }
71
122
 
72
123
  /** Bridges the `task-board` settings form onto the card's staged form. */
@@ -75,12 +126,15 @@ export class TaskBoardSettingsCardController {
75
126
  private readonly store: SnapshotStore<TaskBoardSettingsCardState>
76
127
 
77
128
  /** @param scope - the bound configuration form for the `task-board` namespace. */
78
- constructor(scope: ConfigForm<TaskBoardSettings>) {
129
+ constructor(scope: ConfigForm<TaskBoardSettings>, private readonly dispatch: TaskBoardExtensionDispatch) {
79
130
  this.form = new CardForm(scope, [
80
131
  booleanField('enabled'),
81
132
  booleanField('announceToAgent'),
82
133
  booleanField('preventIdleSleep'),
83
134
  subtaskDepthField(),
135
+ booleanField('goalVerification'),
136
+ judgeModelField(),
137
+ judgeEffortField(),
84
138
  ])
85
139
  this.store = this.form.bind(() => this.projection())
86
140
  }
@@ -92,6 +146,9 @@ export class TaskBoardSettingsCardController {
92
146
  announceToAgent: this.form.field('announceToAgent'),
93
147
  preventIdleSleep: this.form.field('preventIdleSleep'),
94
148
  maxSubtaskDepth: this.form.field('maxSubtaskDepth'),
149
+ goalVerification: this.form.field('goalVerification'),
150
+ goalVerificationModel: this.form.field('goalVerificationModel'),
151
+ goalVerificationReasoningEffort: this.form.field('goalVerificationReasoningEffort'),
95
152
  }
96
153
  }
97
154
 
@@ -100,7 +157,7 @@ export class TaskBoardSettingsCardController {
100
157
  * @returns the card's snapshot and its form actions.
101
158
  */
102
159
  inject(): TaskBoardSettingsCardFace {
103
- return { hooks: { taskBoardSettingsCard: this.store }, ...this.form.actions() }
160
+ return { hooks: { taskBoardSettingsCard: this.store }, ...this.form.actions(), dispatch: this.dispatch }
104
161
  }
105
162
 
106
163
  /**
@@ -117,6 +174,18 @@ export type TaskBoardSettingsCardProps =
117
174
  PropsRuntime<'web-ui.plugin.item'>
118
175
  & PropsLocale<'task-board'>
119
176
  & InjectFace<TaskBoardSettingsCardFace>
177
+ & PropsRenderSlots<'task-board.settings.section'>
178
+
179
+ /** Kanban mark for the card header: three columns of unequal depth. */
180
+ function BoardMark() {
181
+ return (
182
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
183
+ <rect x="2.2" y="2.6" width="3.9" height="12.8" rx="1.95" fill="currentColor" />
184
+ <rect x="7.05" y="2.6" width="3.9" height="7.6" rx="1.95" fill="currentColor" />
185
+ <rect x="11.9" y="2.6" width="3.9" height="10.2" rx="1.95" fill="currentColor" />
186
+ </svg>
187
+ )
188
+ }
120
189
 
121
190
  /**
122
191
  * Render the task-board card.
@@ -124,15 +193,22 @@ export type TaskBoardSettingsCardProps =
124
193
  * @returns the card.
125
194
  */
126
195
  export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
127
- const { t } = props
196
+ const { t, renderSlot, dispatch } = props
128
197
  const state = props.useTaskBoardSettingsCard(snapshot => snapshot)
129
198
  const disabled = !state.writable
130
199
  const [power, setPower] = useState<TaskBoardPowerSnapshot | undefined>()
200
+ const [verification, setVerification] = useState<TaskBoardVerificationOptions | undefined>()
131
201
  useEffect(() => {
132
202
  // The SSE channel already carries power on every real change and pushes
133
203
  // one frame on subscribe; polling the full /state snapshot every 5 s
134
204
  // re-cloned and re-serialized the whole ledger server-side for one field.
135
205
  let live = true
206
+ void fetch('api/task-board/verification')
207
+ .then(r => r.ok ? r.json() : undefined)
208
+ .then((data: TaskBoardVerificationOptions | undefined) => {
209
+ if (data?.catalog && live) setVerification(data)
210
+ })
211
+ .catch(() => {})
136
212
  const events = new EventSource('api/task-board/events')
137
213
  events.onmessage = (message: MessageEvent<string>): void => {
138
214
  try {
@@ -144,17 +220,69 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
144
220
  }
145
221
  return () => { live = false; events.close() }
146
222
  }, [])
223
+ // The staged drafts drive the resolved preview, so an unsaved model or level
224
+ // change already shows what the next execution would freeze (including a
225
+ // reasoning-level fallback the target model forces).
226
+ const catalog: ModelCatalogView = verification?.catalog ?? { groups: [] }
227
+ const stagedSettings: VerificationSettings = {
228
+ enabled: state.goalVerification.text !== 'false',
229
+ model: state.goalVerificationModel.text,
230
+ reasoningEffort: state.goalVerificationReasoningEffort.text,
231
+ }
232
+ const preview = resolveContract(stagedSettings, catalog)
233
+ const inheritRoute = catalog.default
234
+ const modelChoices = [
235
+ {
236
+ value: '',
237
+ label: inheritRoute === undefined
238
+ ? t('settings.goalVerificationModelInheritUnknown')
239
+ : t('settings.goalVerificationModelInherit', { model: inheritRoute.provider + '/' + inheritRoute.model }),
240
+ },
241
+ ...catalog.groups.flatMap(group => group.models.map(model => ({
242
+ value: group.id + '/' + model.id,
243
+ label: (group.name ?? group.id) + ' · ' + (model.name ?? model.id),
244
+ }))),
245
+ ]
246
+ const stagedRoute = parseModelRoute(stagedSettings.model) ?? (inheritRoute === undefined ? undefined : { provider: inheritRoute.provider, model: inheritRoute.model })
247
+ const stagedEfforts = stagedRoute === undefined
248
+ ? []
249
+ : catalog.groups.find(group => group.id === stagedRoute.provider)?.models.find(model => model.id === stagedRoute.model)?.reasoning?.efforts ?? []
250
+ const effortChoices = [
251
+ {
252
+ value: '',
253
+ label: stagedRoute === undefined || stagedEfforts.length === 0
254
+ ? t('settings.goalVerificationEffortInheritUnknown')
255
+ : t('settings.goalVerificationEffortInherit', {
256
+ effort: catalog.groups.find(group => group.id === stagedRoute.provider)?.models
257
+ .find(model => model.id === stagedRoute.model)?.reasoning?.defaultEffort ?? t('settings.goalVerificationEffortInheritUnknown'),
258
+ }),
259
+ },
260
+ ...stagedEfforts.map(effort => ({ value: effort.id, label: effort.name ?? effort.id })),
261
+ ]
147
262
  const fieldProps = {
148
263
  overriddenLabel: t('settings.overridden'),
149
264
  resetLabel: t('settings.reset'),
150
265
  invalidLabel: t('settings.invalidNumber'),
151
266
  disabled,
152
267
  }
268
+ // Chrome state of the nested disclosure cards below: they stage into this
269
+ // card's form and are written by its one save, so they carry no footer and no
270
+ // unsaved pill of their own (the outer header owns the pill).
271
+ const nestedShell: CardShell = {
272
+ available: true,
273
+ exposed: true,
274
+ writable: state.writable,
275
+ dirty: false,
276
+ invalid: state.invalid,
277
+ saving: state.saving,
278
+ failed: false,
279
+ }
153
280
  return (
154
281
  <PluginSettingsCard
155
282
  t={t}
156
283
  titleKey="settings.title"
157
284
  descriptionKey="settings.description"
285
+ icon={<BoardMark />}
158
286
  defaultOpen={false}
159
287
  state={state}
160
288
  renderChildrenWhenNotExposed
@@ -162,66 +290,177 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
162
290
  onSave={props.save}
163
291
  onDiscard={props.discard}
164
292
  >
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>}
293
+ {/* The board's own settings are nested disclosure cards, so an expanded
294
+ card reads as a short topic list: the board and its runtime behavior,
295
+ goal acceptance, and whatever a provider contributes (a provider
296
+ extension registers into the seat rendered last). Every one
297
+ of them starts collapsed and shares this card's single save. */}
298
+ <ul className={settingsCss.nestedCards}>
299
+ <PluginSettingsCard
300
+ t={t}
301
+ titleKey="settings.enabled"
302
+ descriptionKey="settings.enabledCardHint"
303
+ defaultOpen={false}
304
+ hideFooter
305
+ state={nestedShell}
306
+ onSave={props.save}
307
+ onDiscard={props.discard}
308
+ >
309
+ <BooleanField
310
+ id="settings-task-board-enabled"
311
+ label={t('settings.enabled')}
312
+ hint={t('settings.enabledHint')}
313
+ inheritLabel={t('settings.inherit')}
314
+ onLabel={t('settings.on')}
315
+ offLabel={t('settings.off')}
316
+ {...fieldProps}
317
+ {...state.enabled}
318
+ onEdit={(text) => { props.edit('enabled', text) }}
319
+ onReset={() => { props.resetField('enabled') }}
320
+ />
321
+ <BooleanField
322
+ id="settings-task-board-announce"
323
+ label={t('settings.announceToAgent')}
324
+ hint={t('settings.announceToAgentHint')}
325
+ inheritLabel={t('settings.inherit')}
326
+ onLabel={t('settings.on')}
327
+ offLabel={t('settings.off')}
328
+ {...fieldProps}
329
+ {...state.announceToAgent}
330
+ onEdit={(text) => { props.edit('announceToAgent', text) }}
331
+ onReset={() => { props.resetField('announceToAgent') }}
332
+ />
333
+ <BooleanField
334
+ id="settings-task-board-prevent-idle-sleep"
335
+ label={t('settings.preventIdleSleep')}
336
+ hint={t('settings.preventIdleSleepHint')}
337
+ inheritLabel={t('settings.inherit')}
338
+ onLabel={t('settings.on')}
339
+ offLabel={t('settings.off')}
340
+ {...fieldProps}
341
+ {...state.preventIdleSleep}
342
+ onEdit={(text) => { props.edit('preventIdleSleep', text) }}
343
+ onReset={() => { props.resetField('preventIdleSleep') }}
344
+ />
345
+ <ChoiceField
346
+ id="settings-task-board-subtask-depth"
347
+ label={t('settings.maxSubtaskDepth')}
348
+ hint={t('settings.maxSubtaskDepthHint')}
349
+ inheritLabel={t('settings.inherit')}
350
+ choices={SUBTASK_DEPTH_CHOICES.map(value => ({
351
+ value,
352
+ label: t('settings.maxSubtaskDepthOption', { depth: value }),
353
+ }))}
354
+ {...fieldProps}
355
+ {...state.maxSubtaskDepth}
356
+ onEdit={(text) => { props.edit('maxSubtaskDepth', text) }}
357
+ onReset={() => { props.resetField('maxSubtaskDepth') }}
358
+ />
359
+ </PluginSettingsCard>
360
+
361
+ <PluginSettingsCard
362
+ t={t}
363
+ titleKey="settings.goalVerificationTitle"
364
+ descriptionKey="settings.goalVerificationCardHint"
365
+ defaultOpen={false}
366
+ hideFooter
367
+ state={nestedShell}
368
+ onSave={props.save}
369
+ onDiscard={props.discard}
370
+ >
371
+ <BooleanField
372
+ id="settings-task-board-goal-verification"
373
+ label={t('settings.goalVerification')}
374
+ hint={t('settings.goalVerificationHint')}
375
+ inheritLabel={t('settings.inherit')}
376
+ onLabel={t('settings.on')}
377
+ offLabel={t('settings.off')}
378
+ {...fieldProps}
379
+ {...state.goalVerification}
380
+ onEdit={(text) => { props.edit('goalVerification', text) }}
381
+ onReset={() => { props.resetField('goalVerification') }}
382
+ />
383
+ <ChoiceField
384
+ id="settings-task-board-goal-verification-model"
385
+ label={t('settings.goalVerificationModel')}
386
+ hint={t('settings.goalVerificationModelHint')}
387
+ inheritLabel={t('settings.inherit')}
388
+ choices={modelChoices}
389
+ {...fieldProps}
390
+ {...state.goalVerificationModel}
391
+ onEdit={(text) => { props.edit('goalVerificationModel', text) }}
392
+ onReset={() => { props.resetField('goalVerificationModel') }}
393
+ />
394
+ <ChoiceField
395
+ id="settings-task-board-goal-verification-effort"
396
+ label={t('settings.goalVerificationEffort')}
397
+ hint={t('settings.goalVerificationEffortHint')}
398
+ inheritLabel={t('settings.inherit')}
399
+ choices={effortChoices}
400
+ {...fieldProps}
401
+ {...state.goalVerificationReasoningEffort}
402
+ onEdit={(text) => { props.edit('goalVerificationReasoningEffort', text) }}
403
+ onReset={() => { props.resetField('goalVerificationReasoningEffort') }}
404
+ />
405
+ <p className={settingsCss.note}>{t('settings.goalVerificationResolved')}</p>
406
+ {preview.route === undefined
407
+ ? <p className={settingsCss.error}>{t('settings.goalVerificationRouteMissing')}</p>
408
+ : (
409
+ <ul className={settingsCss.resolvedList}>
410
+ <li>{t('settings.goalVerificationResolvedModel', { model: preview.route.provider + '/' + preview.route.model })}</li>
411
+ <li>
412
+ {preview.route.reasoningEffort === undefined
413
+ ? t('settings.goalVerificationResolvedNoEffort')
414
+ : t('settings.goalVerificationResolvedEffort', { effort: preview.route.reasoningEffort })}
415
+ </li>
416
+ <li>
417
+ {t('settings.goalVerificationResolvedSource', {
418
+ source: preview.modelSource === 'inherit'
419
+ ? t('settings.goalVerificationSourceInherit')
420
+ : t('settings.goalVerificationSourceExplicit'),
421
+ })}
422
+ </li>
423
+ <li>{t('settings.goalVerificationResolvedPreset', { threshold: String(preview.threshold) })}</li>
424
+ </ul>
425
+ )}
426
+ {preview.effortFallback !== undefined && (
427
+ <p className={settingsCss.note}>
428
+ {preview.effortFallback.resolved === undefined
429
+ ? t('settings.goalVerificationEffortFallbackNone', { requested: preview.effortFallback.requested })
430
+ : t('settings.goalVerificationEffortFallback', {
431
+ requested: preview.effortFallback.requested,
432
+ resolved: preview.effortFallback.resolved,
433
+ })}
434
+ </p>
435
+ )}
436
+ {stagedSettings.model.trim() !== '' && parseModelRoute(stagedSettings.model) === undefined && (
437
+ <p className={settingsCss.note}>{t('settings.goalVerificationModelInvalid')}</p>
438
+ )}
439
+ </PluginSettingsCard>
440
+
441
+ {/* Provider sections: a family extension contributes its own nested
442
+ settings card into this list through the seat this card declares —
443
+ the family provider extension is the one that does. Each
444
+ contributed card keeps its own chrome and save row, and an empty
445
+ seat draws nothing. */}
446
+ {renderSlot('task-board.settings.section', { dispatch })}
447
+ </ul>
448
+ {/* Live power facts and the battery caveat: small print under the topic
449
+ cards, separated from them, instead of two body-size paragraphs. */}
450
+ <div className={settingsCss.power}>
451
+ <p className={settingsCss.powerLine}>
452
+ {t('settings.powerStatus', {
453
+ platform: power?.platform ?? t('settings.powerUnknown'),
454
+ phase: power?.phase ?? t('settings.powerUnknown'),
455
+ running: String(power?.runningSessions ?? 0),
456
+ schedules: String(power?.armedSchedules ?? 0),
457
+ })}
458
+ </p>
459
+ <p className={settingsCss.powerLine}>{t('settings.powerBoundary')}</p>
460
+ {power?.lastError !== undefined && (
461
+ <p className={settingsCss.powerError}>{t('settings.powerError', { error: power.lastError })}</p>
462
+ )}
463
+ </div>
225
464
  </PluginSettingsCard>
226
465
  )
227
466
  }
@@ -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}>
@@ -10,9 +10,10 @@ import type { BoardController } from '../../core/controller.ts'
10
10
  import { collectKnownTags, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
11
11
  import { t } from '../locales.ts'
12
12
  import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
13
+ import type { OverlayPhase } from './overlay.tsx'
13
14
 
14
15
  /** Edit-task form overlay. */
15
- export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
16
+ export function EditTaskModal({ controller, task, onClose, phase }: { controller: BoardController; task: TaskRecord; onClose: () => void; phase?: OverlayPhase }) {
16
17
  const [title, setTitle] = useState(task.title)
17
18
  const [description, setDescription] = useState(task.description)
18
19
  const [prompt, setPrompt] = useState(task.prompt)
@@ -55,6 +56,7 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
55
56
  submitLabel={t('edit.save')}
56
57
  onSubmit={() => { void submit() }}
57
58
  onClose={onClose}
59
+ phase={phase}
58
60
  >
59
61
  <TaskContentFields
60
62
  title={title}
@@ -71,7 +73,7 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
71
73
  }
72
74
 
73
75
  /** Edit-tags modal: edit labels only, shown for tasks after first execution. */
74
- export function EditTagsModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
76
+ export function EditTagsModal({ controller, task, onClose, phase }: { controller: BoardController; task: TaskRecord; onClose: () => void; phase?: OverlayPhase }) {
75
77
  const [tags, setTags] = useState<TaskTag[]>(task.tags ?? [])
76
78
  const [error, setError] = useState<string | undefined>(undefined)
77
79
  const [pending, setPending] = useState(false)
@@ -99,6 +101,7 @@ export function EditTagsModal({ controller, task, onClose }: { controller: Board
99
101
  submitLabel={t('edit.save')}
100
102
  onSubmit={() => { void submit() }}
101
103
  onClose={onClose}
104
+ phase={phase}
102
105
  >
103
106
  <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
104
107
  </ModalShell>