@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.
- package/README.i18n.yaml +2 -2
- package/README.md +28 -3
- package/README.zh.md +28 -3
- package/lib/client.js +1142 -467
- package/lib/client.js.map +1 -1
- package/lib/index.js +2066 -1475
- package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
- package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
- package/lib/types/client/board/NewTaskModal.d.ts +7 -1
- package/lib/types/client/board/TaskCard.d.ts +11 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +33 -0
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +71 -3
- package/lib/types/core/subtask.d.ts +122 -0
- package/lib/types/core/tasks.d.ts +46 -3
- package/lib/types/core/use-cases/task-archive.d.ts +10 -13
- package/lib/types/core/use-cases/task-create.d.ts +13 -4
- package/lib/types/core/use-cases/task-delete.d.ts +7 -1
- package/lib/types/core/use-cases/task-parent.d.ts +21 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/agent-tools.d.ts +27 -0
- package/lib/types/host-ledger.d.ts +97 -4
- package/lib/types/host-runner.d.ts +39 -1
- package/lib/types/host-service.d.ts +98 -5
- package/lib/types/index.d.ts +93 -5
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +14 -0
- package/package.json +26 -21
- package/src/client/TaskBoardSettingsCard.tsx +48 -3
- package/src/client/board/LinkSubtaskModal.tsx +82 -0
- package/src/client/board/NewTaskModal.tsx +40 -6
- package/src/client/board/TaskBoard.tsx +88 -8
- package/src/client/board/TaskCard.tsx +52 -2
- package/src/client/board/TaskDetail.tsx +124 -1
- package/src/client/board.module.css +112 -131
- package/src/client/host-api.ts +14 -4
- package/src/client/index.ts +56 -18
- package/src/client/locales.ts +70 -2
- package/src/client/native-panel.tsx +124 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +128 -10
- package/src/core/store.ts +15 -0
- package/src/core/subtask.ts +286 -0
- package/src/core/tasks.ts +50 -2
- package/src/core/use-cases/task-archive.ts +46 -12
- package/src/core/use-cases/task-create.ts +42 -7
- package/src/core/use-cases/task-delete.ts +11 -1
- package/src/core/use-cases/task-parent.ts +64 -0
- package/src/core/use-cases/task-update.ts +3 -1
- package/src/host/agent-tools.ts +643 -0
- package/src/host-ledger.ts +343 -45
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +141 -12
- package/src/host-service.ts +265 -32
- package/src/index.ts +203 -9
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +26 -2
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- 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
|
|
8
|
-
*
|
|
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.
|
|
13
|
-
*
|
|
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
|
|
19
|
-
*
|
|
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.
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
55
|
-
"@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.
|
|
56
|
-
"@deepseek-ai/dsh-api-gateway": "^0.1.7-rc.
|
|
57
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.
|
|
58
|
-
"@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.
|
|
59
|
-
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.
|
|
60
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.7-rc.
|
|
61
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.
|
|
62
|
-
"@deepseek-ai/dsh-client-store": "^0.1.7-rc.
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-
|
|
66
|
-
"@deepseek-ai/dsh-client-ui-
|
|
67
|
-
"@deepseek-ai/dsh-
|
|
68
|
-
"@deepseek-ai/dsh-
|
|
69
|
-
"@deepseek-ai/dsh-
|
|
70
|
-
"@deepseek-ai/dsh-
|
|
71
|
-
"@deepseek-ai/dsh-
|
|
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 {
|
|
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('
|
|
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
|
-
|
|
37
|
-
|
|
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 =
|
|
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="">{
|
|
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
|
|
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
|
|
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
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
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>
|