@linxin666/dsh-client-ui-task-board 0.4.1 → 0.4.3

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 (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +28 -3
  3. package/README.zh.md +28 -3
  4. package/lib/client.js +1142 -467
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2066 -1475
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
  8. package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
  9. package/lib/types/client/board/NewTaskModal.d.ts +7 -1
  10. package/lib/types/client/board/TaskCard.d.ts +11 -1
  11. package/lib/types/client/index.d.ts +15 -1
  12. package/lib/types/client/locales.d.ts +33 -0
  13. package/lib/types/client/native-panel.d.ts +62 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +71 -3
  16. package/lib/types/core/subtask.d.ts +122 -0
  17. package/lib/types/core/tasks.d.ts +46 -3
  18. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  19. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  20. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  21. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/agent-tools.d.ts +27 -0
  24. package/lib/types/host-ledger.d.ts +97 -4
  25. package/lib/types/host-runner.d.ts +39 -1
  26. package/lib/types/host-service.d.ts +98 -5
  27. package/lib/types/index.d.ts +93 -5
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +14 -0
  30. package/package.json +26 -21
  31. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  32. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  33. package/src/client/board/NewTaskModal.tsx +40 -6
  34. package/src/client/board/TaskBoard.tsx +88 -8
  35. package/src/client/board/TaskCard.tsx +52 -2
  36. package/src/client/board/TaskDetail.tsx +124 -1
  37. package/src/client/board.module.css +112 -131
  38. package/src/client/host-api.ts +14 -4
  39. package/src/client/index.ts +56 -18
  40. package/src/client/locales.ts +70 -2
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/settings-entry-form.ts +142 -0
  43. package/src/core/controller.ts +128 -10
  44. package/src/core/store.ts +15 -0
  45. package/src/core/subtask.ts +286 -0
  46. package/src/core/tasks.ts +50 -2
  47. package/src/core/use-cases/task-archive.ts +46 -12
  48. package/src/core/use-cases/task-create.ts +42 -7
  49. package/src/core/use-cases/task-delete.ts +11 -1
  50. package/src/core/use-cases/task-parent.ts +64 -0
  51. package/src/core/use-cases/task-update.ts +3 -1
  52. package/src/host/agent-tools.ts +643 -0
  53. package/src/host-ledger.ts +343 -45
  54. package/src/host-routes.ts +37 -1
  55. package/src/host-runner.ts +141 -12
  56. package/src/host-service.ts +265 -32
  57. package/src/index.ts +203 -9
  58. package/src/mount-once.ts +107 -13
  59. package/src/protocol.ts +26 -2
  60. package/lib/types/client/board-mount.d.ts +0 -23
  61. package/lib/types/client/body-mutations.d.ts +0 -45
  62. package/lib/types/client/panel-mount-core.d.ts +0 -56
  63. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  64. package/lib/types/client/sidebar-entry.d.ts +0 -29
  65. package/src/client/board-mount.tsx +0 -43
  66. package/src/client/body-mutations.ts +0 -133
  67. package/src/client/panel-mount-core.ts +0 -155
  68. package/src/client/sidebar-entry-core.ts +0 -237
  69. package/src/client/sidebar-entry.ts +0 -53
@@ -4,19 +4,50 @@
4
4
  * the loader accepts a standalone install of the same package side by side;
5
5
  * without this guard the second instance would still re-register the same
6
6
  * webserver routes, tools, settings namespaces, and system-prompt sections
7
- * and fail the boot. mountOnce makes the second host apply a no-op for the
8
- * lifetime of the first instance (the browser half is already deduped by
9
- * package name in the client module host).
7
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
8
+ * no-op for as long as the first instance lives (the browser half is already
9
+ * deduped by package name in the client module host).
10
+ *
11
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
12
+ * long after the refused mount ran its course: the Host reloads a profile by
13
+ * creating the new loader entries before the old ones are torn down (a
14
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
15
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
16
+ * previous entry still owns the name. Dropping that refused mount lost the
17
+ * plugin for good - the previous entry then disposed its own mount, releasing
18
+ * the name with nobody left to take it, and the family row stayed listed as
19
+ * active while its host routes 404ed (the task-board panel showed
20
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
21
+ * restart recovered it).
22
+ *
23
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
24
+ * the holder releases the name - if the waiting fiber is still alive then. The
25
+ * single-instance guarantee is unchanged: exactly one mount is live per
26
+ * package name, and the replay re-enters the guard so a later mount still
27
+ * dedupes against it.
10
28
  *
11
29
  * The registry rides a global symbol so two module instances of the same
12
- * package (npm copy vs repository link) still share one verdict. cordis
13
- * `ctx.effect` runs its callback immediately and treats the callback's
30
+ * package (npm copy vs repository link) still share one verdict. That symbol
31
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
32
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
33
+ * dsh-community-plugins) are separate repositories carrying their own copy of
34
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
35
+ * must keep the shape every published copy reads (a `Set` of package names
36
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
37
+ * under their own additive key, and the registry reads back a `Set` even when
38
+ * some other build left a different value there. Changing `MOUNTED`'s shape
39
+ * in place broke that contract once: a satellite's legacy copy created a
40
+ * `Set`, the family's new copy read it as a `Map`, and every family row
41
+ * mounted after it failed with "claims.get is not a function".
42
+ *
43
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
14
44
  * return value as the fiber disposer, so the unmarker is returned, not run.
15
45
  */
16
46
  /**
17
47
  * Wrap a cordis plugin apply so the package runs at most once per process.
18
- * The first mount registers normally and unmarks when its fiber disposes;
19
- * any later mount of the same package name is a no-op.
48
+ * The first mount registers normally and releases the name when its fiber
49
+ * disposes; a mount refused while that name is held waits for the release and
50
+ * then runs, unless its own fiber disposes first.
20
51
  * @param packageName - npm package identity shared by every install source.
21
52
  * @param fn - the original plugin apply.
22
53
  * @returns an apply of the same shape.
@@ -29,6 +29,16 @@ export interface TaskBoardSnapshot {
29
29
  power: TaskBoardPowerSnapshot;
30
30
  /** Session-default permission the confirmation gate compares against. */
31
31
  sessionDefaultPermission?: TaskPermission;
32
+ /**
33
+ * Deployment subtask depth limit (1..3). The browser mirrors it to enable or
34
+ * disable the subtask controls; the Host ledger is the authority.
35
+ */
36
+ maxSubtaskDepth?: number;
37
+ /**
38
+ * Whether this deployment serves the Agent Teams service, so a task may opt
39
+ * into team execution (Team Lead session plus one teammate per subtask).
40
+ */
41
+ teamRunAvailable?: boolean;
32
42
  }
33
43
  /** SSE event frame: revision/scheduler/power only, never the task list. */
34
44
  export interface TaskBoardEventPayload {
@@ -96,6 +106,10 @@ export type TaskBoardAction = {
96
106
  } | {
97
107
  kind: 'confirm-permission';
98
108
  taskId: string;
109
+ } | {
110
+ kind: 'set-parent';
111
+ taskId: string;
112
+ parentId: string | null;
99
113
  };
100
114
  export interface TaskBoardActionEnvelope {
101
115
  requestId: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.4.1",
4
+ "version": "0.4.3",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dsh": {
26
26
  "engines": {
27
- "dsh": ">=0.1.7-rc.1"
27
+ "dsh": ">=0.1.7-rc.2"
28
28
  },
29
29
  "bundle": {
30
30
  "patch": "./cordis.patch.yml"
@@ -34,6 +34,7 @@
34
34
  "@deepseek-ai/dsh-client-connection",
35
35
  "@deepseek-ai/dsh-client-ui-settings",
36
36
  "@deepseek-ai/dsh-client-ui-renderer",
37
+ "@deepseek-ai/dsh-client-ui-layout",
37
38
  "@deepseek-ai/dsh-api-session-controller",
38
39
  "@deepseek-ai/dsh-api-workspace-controller",
39
40
  "@deepseek-ai/dsh-api-remotes",
@@ -43,7 +44,7 @@
43
44
  }
44
45
  },
45
46
  "peerDependencies": {
46
- "@deepseek-ai/dsh": ">=0.1.7-rc.1",
47
+ "@deepseek-ai/dsh": ">=0.1.7-rc.2",
47
48
  "react": "^18.2.0"
48
49
  },
49
50
  "dependencies": {
@@ -51,24 +52,28 @@
51
52
  },
52
53
  "devDependencies": {
53
54
  "@deepseek-ai/cordis": "^4.0.4",
54
- "@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
55
- "@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.1",
56
- "@deepseek-ai/dsh-api-gateway": "^0.1.7-rc.1",
57
- "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
58
- "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
59
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.1",
60
- "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
61
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
62
- "@deepseek-ai/dsh-client-store": "^0.1.7-rc.1",
63
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
64
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
65
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
66
- "@deepseek-ai/dsh-client-ui-workspace": "^0.1.7-rc.1",
67
- "@deepseek-ai/dsh-commands": "^0.1.7-rc.1",
68
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
69
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
70
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.1",
71
- "@deepseek-ai/dsh-workspace": "^0.1.7-rc.1",
55
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.2",
57
+ "@deepseek-ai/dsh-api-gateway": "^0.1.7-rc.2",
58
+ "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.2",
59
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.2",
61
+ "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
62
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
63
+ "@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
64
+ "@deepseek-ai/dsh-client-ui-layout": "^0.1.7-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
66
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
67
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
68
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
69
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-commands": "^0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
72
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
73
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
74
+ "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.2",
75
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
76
+ "@deepseek-ai/dsh-workspace": "^0.1.7-rc.2",
72
77
  "@types/node": "^22.20.0",
73
78
  "@types/react": "~18.3.1",
74
79
  "@types/react-dom": "^18.3.5",
@@ -9,8 +9,33 @@ 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
11
  import type { TaskBoardPowerSnapshot } from '../protocol.ts'
12
- import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
13
- import { CardForm, booleanField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
12
+ import { PluginSettingsCard, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
13
+ import { SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from '../core/subtask.ts'
14
+ import { CardForm, booleanField, type CardActions, type CardShell, type FieldSpec, type FieldState as CardFieldState } from './settings-form.ts'
15
+
16
+ /** The depth choices the card offers, derived from the supported range. */
17
+ const SUBTASK_DEPTH_CHOICES: readonly string[] = Array.from(
18
+ { length: SUBTASK_DEPTH_MAX - SUBTASK_DEPTH_MIN + 1 },
19
+ (_, index) => String(SUBTASK_DEPTH_MIN + index),
20
+ )
21
+
22
+ /**
23
+ * The depth field: a choice among the supported levels whose draft text is a
24
+ * number, because the Host schema (`maxSubtaskDepth`) is numeric. A draft
25
+ * outside the range blocks the save instead of staging a value the Host
26
+ * refuses.
27
+ */
28
+ function subtaskDepthField(): FieldSpec {
29
+ return {
30
+ field: 'maxSubtaskDepth',
31
+ format: value => typeof value === 'number' && Number.isInteger(value) ? String(value) : '',
32
+ parse: (text) => {
33
+ const trimmed = text.trim()
34
+ if (trimmed === '') return { kind: 'clear' }
35
+ return SUBTASK_DEPTH_CHOICES.includes(trimmed) ? { kind: 'set', value: Number(trimmed) } : undefined
36
+ },
37
+ }
38
+ }
14
39
 
15
40
  /** The task-board fields this card edits (the namespace's full schema). */
16
41
  export interface TaskBoardSettings {
@@ -20,6 +45,8 @@ export interface TaskBoardSettings {
20
45
  announceToAgent?: boolean
21
46
  /** Prevent host idle sleep while sessions run or schedules are armed. */
22
47
  preventIdleSleep?: boolean
48
+ /** Subtask depth limit (1..3); 1 means a single level of subtasks. */
49
+ maxSubtaskDepth?: number
23
50
  }
24
51
 
25
52
  /** What the task-board card renders. */
@@ -30,6 +57,8 @@ export interface TaskBoardSettingsCardState extends CardShell {
30
57
  announceToAgent: CardFieldState
31
58
  /** Idle-system-sleep protection flag. */
32
59
  preventIdleSleep: CardFieldState
60
+ /** Subtask depth limit field. */
61
+ maxSubtaskDepth: CardFieldState
33
62
  }
34
63
 
35
64
  /** The registration-side face the card's slot entry injects. */
@@ -51,6 +80,7 @@ export class TaskBoardSettingsCardController {
51
80
  booleanField('enabled'),
52
81
  booleanField('announceToAgent'),
53
82
  booleanField('preventIdleSleep'),
83
+ subtaskDepthField(),
54
84
  ])
55
85
  this.store = this.form.bind(() => this.projection())
56
86
  }
@@ -61,6 +91,7 @@ export class TaskBoardSettingsCardController {
61
91
  enabled: this.form.field('enabled'),
62
92
  announceToAgent: this.form.field('announceToAgent'),
63
93
  preventIdleSleep: this.form.field('preventIdleSleep'),
94
+ maxSubtaskDepth: this.form.field('maxSubtaskDepth'),
64
95
  }
65
96
  }
66
97
 
@@ -102,7 +133,7 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
102
133
  // one frame on subscribe; polling the full /state snapshot every 5 s
103
134
  // re-cloned and re-serialized the whole ledger server-side for one field.
104
135
  let live = true
105
- const events = new EventSource('/api/task-board/events')
136
+ const events = new EventSource('api/task-board/events')
106
137
  events.onmessage = (message: MessageEvent<string>): void => {
107
138
  try {
108
139
  const frame = JSON.parse(message.data) as { power?: TaskBoardPowerSnapshot }
@@ -167,6 +198,20 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
167
198
  onEdit={(text) => { props.edit('preventIdleSleep', text) }}
168
199
  onReset={() => { props.resetField('preventIdleSleep') }}
169
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
+ />
170
215
  <p>
171
216
  {t('settings.powerStatus', {
172
217
  platform: power?.platform ?? t('settings.powerUnknown'),
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Link-subtask modal: attach an existing on-board root task under the open
3
+ * task. The candidate list mirrors the Host lineage gate (checkParentLink), so
4
+ * a link the Host would refuse is not offered in the first place; the Host
5
+ * still re-checks the action at submit time.
6
+ */
7
+ import { useState } from 'react'
8
+ import type { BoardController } from '../../core/controller.ts'
9
+ import { DEFAULT_SUBTASK_DEPTH, checkParentLink } from '../../core/subtask.ts'
10
+ import type { TaskRecord } from '../../core/tasks.ts'
11
+ import { t } from '../locales.ts'
12
+ import css from '../board.module.css'
13
+ import { STATUS_KEY } from './status-key.ts'
14
+
15
+ /** Link-subtask overlay props. */
16
+ export interface LinkSubtaskModalProps {
17
+ controller: BoardController
18
+ /** The task the picked card becomes a subtask of. */
19
+ parent: TaskRecord
20
+ onClose: () => void
21
+ }
22
+
23
+ /** Link-subtask overlay. */
24
+ export function LinkSubtaskModal({ controller, parent, onClose }: LinkSubtaskModalProps) {
25
+ const [pending, setPending] = useState(false)
26
+ const [error, setError] = useState<string | undefined>(undefined)
27
+ const snapshot = controller.getSnapshot()
28
+ const limit = snapshot.host?.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH
29
+ // Only root, on-board tasks can move under another parent; the lineage gate
30
+ // decides the rest (depth, cycle, archived parent).
31
+ const candidates = snapshot.tasks.filter(task =>
32
+ task.archivedAt === undefined
33
+ // A running task may already be a participant of another run group; the
34
+ // Host refuses the move, so it is not offered here.
35
+ && task.status !== 'running'
36
+ && task.parentId === undefined
37
+ && task.id !== parent.id
38
+ && checkParentLink(snapshot.tasks, task.id, parent.id, limit).ok,
39
+ )
40
+ const link = async (taskId: string): Promise<void> => {
41
+ setPending(true)
42
+ setError(undefined)
43
+ if (await controller.setParent(taskId, parent.id)) {
44
+ onClose()
45
+ return
46
+ }
47
+ setPending(false)
48
+ setError(controller.getSnapshot().transportError ?? t('new.required'))
49
+ }
50
+ return (
51
+ <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
52
+ <div className={css.modal} role="dialog" aria-label={t('link.subtask.title')}>
53
+ <h2 className={css.modalTitle}>{t('link.subtask.title')}</h2>
54
+ <p className={css.fieldHint}>{t('link.subtask.hint')}</p>
55
+ {candidates.length === 0
56
+ ? <p className={css.detailText}>{t('link.subtask.empty')}</p>
57
+ : (
58
+ <ul className={css.pickList}>
59
+ {candidates.map(task => (
60
+ <li key={task.id} className={css.pickRow}>
61
+ <span className={css.pickTitle}>{task.title}</span>
62
+ <span className={css.statusBadge} data-status={task.status}>{t(STATUS_KEY[task.status])}</span>
63
+ <button
64
+ type="button"
65
+ className={css.primaryButton}
66
+ disabled={pending}
67
+ onClick={() => { void link(task.id) }}
68
+ >
69
+ {t('link.subtask.confirm')}
70
+ </button>
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ )}
75
+ {error !== undefined && <p className={css.formError}>{error}</p>}
76
+ <footer className={css.modalFooter}>
77
+ <button type="button" className={css.ghostButton} onClick={onClose}>{t('new.cancel')}</button>
78
+ </footer>
79
+ </div>
80
+ </div>
81
+ )
82
+ }
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
8
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
+ import { effectiveTaskPermission } from '../../core/subtask.ts'
9
10
  import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
10
11
  import { t, type TaskBoardKey } from '../locales.ts'
11
12
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
@@ -25,18 +26,30 @@ export interface NewTaskModalProps {
25
26
  defaultWorkspaceId?: string
26
27
  /** Optional callback after successful duplication (e.g. to archive source). */
27
28
  onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
29
+ /**
30
+ * Task the new card becomes a subtask of ("add subtask" from its detail
31
+ * view). The execution targets start from the parent's and the create action
32
+ * carries the parent link; unset targets inherit again at run time.
33
+ */
34
+ parentTask?: TaskRecord
28
35
  }
29
36
 
30
37
  /** New-task form overlay. */
31
- export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps) {
38
+ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess, parentTask }: NewTaskModalProps) {
32
39
  const isDuplicate = initialTask !== undefined
33
40
  const [title, setTitle] = useState(initialTask?.title ?? '')
34
41
  const [description, setDescription] = useState(initialTask?.description ?? '')
35
42
  const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
36
- const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? defaultWorkspaceId ?? '')
37
- const [mode, setMode] = useState(initialTask?.mode ?? '')
43
+ // A subtask starts from its parent's execution contract (issue: subtask
44
+ // inheritance); each picker below still overrides it for this card. The
45
+ // permission stays UNSET by default: leaving it empty is what makes the Host
46
+ // inherit the parent's binding together with its human confirmation, while
47
+ // picking a value pins this card's own binding and re-arms the gate.
48
+ const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? parentTask?.workspaceId ?? defaultWorkspaceId ?? '')
49
+ const [mode, setMode] = useState(initialTask?.mode ?? parentTask?.mode ?? '')
38
50
  const [permission, setPermission] = useState(initialTask?.permission ?? '')
39
- const [model, setModel] = useState(initialTask?.model ?? '')
51
+ const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
52
+ const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
40
53
  const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
41
54
  const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
42
55
  const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
@@ -62,6 +75,13 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
62
75
  const [parseError, setParseError] = useState<string | undefined>(undefined)
63
76
  const parseAbort = useRef<AbortController | undefined>(undefined)
64
77
  const parseModels = options.models ?? []
78
+ // A pinned target may have disappeared from the runtime (workspace deleted,
79
+ // preset removed) — especially when the form starts from a subtask's parent.
80
+ // Keep it selectable as a stale row, so the field shows what the create
81
+ // action will actually send instead of silently displaying the default.
82
+ const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
83
+ const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
84
+ const modelKnown = model === '' || parseModels.some(item => item.id === model)
65
85
 
66
86
  // The workspace list and preset roster arrive from the runtime after mount;
67
87
  // follow them so the pickers never freeze on an empty snapshot.
@@ -147,6 +167,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
147
167
  title,
148
168
  description,
149
169
  prompt,
170
+ ...(parentTask === undefined ? {} : { parentId: parentTask.id }),
150
171
  freeze,
151
172
  handover,
152
173
  workspaceId: workspaceId === '' ? undefined : workspaceId,
@@ -185,7 +206,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
185
206
  ? nextRunAtMs(scheduleCron, Date.now())
186
207
  : undefined
187
208
 
188
- const modalTitle = isDuplicate ? t('new.duplicateTitle') : t('board.new')
209
+ const modalTitle = parentTask !== undefined
210
+ ? t('new.subtaskTitle')
211
+ : isDuplicate ? t('new.duplicateTitle') : t('board.new')
189
212
 
190
213
  return (
191
214
  <ModalShell
@@ -246,6 +269,12 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
246
269
  </section>
247
270
  )}
248
271
 
272
+ {parentTask !== undefined && (
273
+ <p className={css.detailText} data-dsh-part="subtask-inherit">
274
+ {t('detail.parent')}: {parentTask.title} · {t('new.subtaskInherit')}
275
+ </p>
276
+ )}
277
+
249
278
  <TaskContentFields
250
279
  title={title}
251
280
  description={description}
@@ -290,6 +319,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
290
319
  onChange={event => { setWorkspaceId(event.target.value) }}
291
320
  >
292
321
  <option value="">{t('exec.workspace.recent')}</option>
322
+ {!workspaceKnown && <option value={workspaceId}>{workspaceId}{t('exec.mode.removed')}</option>}
293
323
  {options.workspaces.map(workspace => (
294
324
  <option key={workspace.workspaceId} value={workspace.workspaceId}>{workspace.title}</option>
295
325
  ))}
@@ -304,6 +334,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
304
334
  onChange={event => { setMode(event.target.value) }}
305
335
  >
306
336
  <option value="">{t('exec.mode.default')}</option>
337
+ {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
307
338
  {options.presets.map(preset => (
308
339
  <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
309
340
  {preset.name ?? preset.id}
@@ -321,7 +352,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
321
352
  value={permission}
322
353
  onChange={event => { setPermission(event.target.value) }}
323
354
  >
324
- <option value="">{t('exec.permission.default')}</option>
355
+ <option value="">{inheritedPermission === undefined
356
+ ? t('exec.permission.default')
357
+ : t('exec.permission.inheritParent', { permission: t(`exec.permission.${inheritedPermission}` as TaskBoardKey) })}</option>
325
358
  {TASK_PERMISSIONS.map(id => (
326
359
  <option key={id} value={id}>{t(`exec.permission.${id}` as TaskBoardKey)}</option>
327
360
  ))}
@@ -336,6 +369,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
336
369
  onChange={event => { setModel(event.target.value) }}
337
370
  >
338
371
  <option value="">{t('exec.model.default')}</option>
372
+ {!modelKnown && <option value={model}>{model}{t('exec.model.unknown')}</option>}
339
373
  {options.models?.map(item => (
340
374
  <option key={item.id} value={item.id}>{item.name ?? item.id}</option>
341
375
  ))}
@@ -43,9 +43,31 @@ export function matchesTagFilter(task: TaskRecord, selected: readonly string[]):
43
43
  * re-renders only when its own task changes — not when a sibling card status,
44
44
  * the filter, or the selection moves.
45
45
  */
46
- const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen }: { task: TaskRecord; pending: boolean; timeZone?: string; onOpen: (id: string) => void }) {
46
+ const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen, subtaskCount, isSubtask, subtasksDone, subtasksRunning, subtasksFailed }: {
47
+ task: TaskRecord
48
+ pending: boolean
49
+ timeZone?: string
50
+ onOpen: (id: string) => void
51
+ subtaskCount: number
52
+ isSubtask: boolean
53
+ subtasksDone: number
54
+ subtasksRunning: number
55
+ subtasksFailed: number
56
+ }) {
47
57
  const onClick = useCallback(() => { onOpen(task.id) }, [task.id, onOpen])
48
- return <TaskCard task={task} pending={pending} timeZone={timeZone} onClick={onClick} />
58
+ return (
59
+ <TaskCard
60
+ task={task}
61
+ pending={pending}
62
+ timeZone={timeZone}
63
+ onClick={onClick}
64
+ subtaskCount={subtaskCount}
65
+ isSubtask={isSubtask}
66
+ subtasksDone={subtasksDone}
67
+ subtasksRunning={subtasksRunning}
68
+ subtasksFailed={subtasksFailed}
69
+ />
70
+ )
49
71
  })
50
72
 
51
73
  /** Board component; subscribes to the controller snapshot. */
@@ -57,6 +79,11 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
57
79
  )
58
80
  const [filter, setFilter] = useState('')
59
81
  const [tagFilter, setTagFilter] = useState<string[]>([])
82
+ // Subtask cards are real board cards, but a tree with dozens of children
83
+ // turns a column into a wall: the board starts from the parent cards only and
84
+ // the header switch reveals the flat view. A text or label filter re-enables
85
+ // them automatically, so searching a subtask title still finds it.
86
+ const [hideSubtasks, setHideSubtasks] = useState(true)
60
87
  const [showNew, setShowNew] = useState(false)
61
88
  // Project partition (#1536): '' means "all projects". A selected project
62
89
  // narrows the board and becomes the new-task form's default workspace.
@@ -70,10 +97,29 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
70
97
  // Every label in use across the ledger (board and archive alike), so the
71
98
  // filter never loses an option just because its task was archived.
72
99
  const knownTags = collectKnownTags(snapshot.tasks)
73
- // Archived tasks leave the columns; the archive view shows them instead.
74
- const visible = snapshot.tasks.filter(task =>
75
- (archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
76
- && (projectId === '' || task.workspaceId === projectId)
100
+ // The family of cards this view owns: the board columns, or the archive.
101
+ const onBoard = snapshot.tasks.filter(task =>
102
+ archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined,
103
+ )
104
+ // Direct subtask count and state roll-up per task, for the card badge: a
105
+ // hidden tree still has to report how many children run, fail, or finish.
106
+ const subtaskCounts = new Map<string, number>()
107
+ const subtaskRollup = new Map<string, { done: number; running: number; failed: number }>()
108
+ for (const task of onBoard) {
109
+ if (task.parentId === undefined) continue
110
+ subtaskCounts.set(task.parentId, (subtaskCounts.get(task.parentId) ?? 0) + 1)
111
+ const rollup = subtaskRollup.get(task.parentId) ?? { done: 0, running: 0, failed: 0 }
112
+ if (task.status === 'done') rollup.done += 1
113
+ else if (task.status === 'running') rollup.running += 1
114
+ else if (task.status === 'failed') rollup.failed += 1
115
+ subtaskRollup.set(task.parentId, rollup)
116
+ }
117
+ const hasSubtasks = subtaskCounts.size > 0
118
+ const searchActive = filter.trim() !== '' || tagFilter.length > 0
119
+ const hidingSubtasks = hideSubtasks && !searchActive
120
+ const visible = onBoard.filter(task =>
121
+ (projectId === '' || task.workspaceId === projectId)
122
+ && (!hidingSubtasks || task.parentId === undefined)
77
123
  && matchesFilter(task, filter)
78
124
  && matchesTagFilter(task, tagFilter),
79
125
  )
@@ -159,6 +205,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
159
205
  onChange={event => { setFilter(event.target.value) }}
160
206
  aria-label={t('board.search')}
161
207
  />
208
+ {hasSubtasks && (
209
+ <button
210
+ type="button"
211
+ className={hidingSubtasks ? css.primaryButton : css.ghostButton}
212
+ data-dsh-part="subtask-filter"
213
+ aria-pressed={hidingSubtasks}
214
+ title={t('board.subtaskFilterHint')}
215
+ onClick={() => { setHideSubtasks(value => !value) }}
216
+ >
217
+ {hidingSubtasks ? t('board.showSubtasks') : t('board.hideSubtasks')}
218
+ </button>
219
+ )}
162
220
  <button
163
221
  type="button"
164
222
  className={archiveView ? css.primaryButton : css.ghostButton}
@@ -257,7 +315,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
257
315
  </header>
258
316
  <div className={css.cards}>
259
317
  {visible.map(task => (
260
- <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
318
+ <MemoTaskCard
319
+ key={task.id}
320
+ task={task}
321
+ pending={snapshot.pendingTaskIds.includes(task.id)}
322
+ timeZone={snapshot.host?.scheduler.timeZone}
323
+ onOpen={openTask}
324
+ subtaskCount={subtaskCounts.get(task.id) ?? 0}
325
+ isSubtask={task.parentId !== undefined}
326
+ subtasksDone={subtaskRollup.get(task.id)?.done ?? 0}
327
+ subtasksRunning={subtaskRollup.get(task.id)?.running ?? 0}
328
+ subtasksFailed={subtaskRollup.get(task.id)?.failed ?? 0}
329
+ />
261
330
  ))}
262
331
  {visible.length === 0 && (
263
332
  <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('archive.empty')}</div>
@@ -295,7 +364,18 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
295
364
  </header>
296
365
  <div className={css.cards}>
297
366
  {tasks.map(task => (
298
- <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
367
+ <MemoTaskCard
368
+ key={task.id}
369
+ task={task}
370
+ pending={snapshot.pendingTaskIds.includes(task.id)}
371
+ timeZone={snapshot.host?.scheduler.timeZone}
372
+ onOpen={openTask}
373
+ subtaskCount={subtaskCounts.get(task.id) ?? 0}
374
+ isSubtask={task.parentId !== undefined}
375
+ subtasksDone={subtaskRollup.get(task.id)?.done ?? 0}
376
+ subtasksRunning={subtaskRollup.get(task.id)?.running ?? 0}
377
+ subtasksFailed={subtaskRollup.get(task.id)?.failed ?? 0}
378
+ />
299
379
  ))}
300
380
  {tasks.length === 0 && (
301
381
  <div className={css.columnEmpty}>{tagFilter.length > 0 ? t('board.tagEmpty') : t('board.empty')}</div>