@linxin666/dsh-client-ui-task-board 0.3.3 → 0.3.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.
- package/lib/client.js +399 -201
- package/lib/client.js.map +1 -1
- package/lib/index.js +42 -6
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/board/EditTaskModal.d.ts +8 -0
- package/lib/types/client/board/TaskCard.d.ts +0 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/core/controller.d.ts +21 -5
- package/lib/types/core/use-cases/task-update.d.ts +19 -3
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/board/EditTaskModal.tsx +94 -0
- package/src/client/board/TaskBoard.tsx +21 -2
- package/src/client/board/TaskCard.tsx +7 -1
- package/src/client/board/TaskDetail.tsx +19 -0
- package/src/client/locales.ts +6 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/core/controller.ts +29 -21
- package/src/core/use-cases/task-update.ts +33 -3
- package/src/dsh-home.ts +9 -3
- package/src/host-ledger.ts +9 -1
package/lib/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
|
|
5
5
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, isAbsolute, join, win32 } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
+
import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
|
|
8
9
|
//#region src/core/schedule.ts
|
|
9
10
|
/** Inclusive ranges per field, in cron order. */
|
|
10
11
|
const FIELD_RANGES = [
|
|
@@ -182,8 +183,9 @@ function isDigits(value) {
|
|
|
182
183
|
*/
|
|
183
184
|
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
184
185
|
function expandHome(path, home = homedir()) {
|
|
186
|
+
const j = home.startsWith("/") ? join$1 : join;
|
|
185
187
|
if (path === "~") return home;
|
|
186
|
-
if (path.startsWith("~/") || path.startsWith("~\\")) return
|
|
188
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
|
|
187
189
|
return path;
|
|
188
190
|
}
|
|
189
191
|
/**
|
|
@@ -193,12 +195,15 @@ function expandHome(path, home = homedir()) {
|
|
|
193
195
|
* @returns the absolute DSH home path.
|
|
194
196
|
*/
|
|
195
197
|
function resolveDshHome(env = process.env, home = homedir()) {
|
|
198
|
+
const isPosix = home.startsWith("/");
|
|
199
|
+
const j = isPosix ? join$1 : join;
|
|
200
|
+
const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
|
|
196
201
|
const raw = env.DSH_HOME;
|
|
197
202
|
if (raw !== void 0 && raw.trim() !== "") {
|
|
198
203
|
const expanded = expandHome(raw.trim(), home);
|
|
199
|
-
return
|
|
204
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
|
|
200
205
|
}
|
|
201
|
-
return
|
|
206
|
+
return j(home, ".dsh");
|
|
202
207
|
}
|
|
203
208
|
/** Resolve the DSH home directory from the live environment. */
|
|
204
209
|
function dshHome() {
|
|
@@ -611,10 +616,34 @@ function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
|
|
|
611
616
|
* fresh updatedAt. Pure ledger transition (no persistence or notify — the
|
|
612
617
|
* controller orchestrates those).
|
|
613
618
|
*
|
|
614
|
-
* An explicit `undefined` in the patch clears
|
|
615
|
-
* back to the runtime default);
|
|
616
|
-
*
|
|
619
|
+
* An explicit `undefined` in the patch clears an execution-target field
|
|
620
|
+
* (the task falls back to the runtime default); content fields are trimmed
|
|
621
|
+
* and never cleared. An unknown permission string is ignored so stale UI
|
|
622
|
+
* can never persist a value the execution service rejects.
|
|
617
623
|
*/
|
|
624
|
+
/**
|
|
625
|
+
* The fields that edit the task's content (what the user reads and what the
|
|
626
|
+
* next execution sends). Unlike the execution targets they stay editable only
|
|
627
|
+
* while the task has never started executing — after the first run the
|
|
628
|
+
* recorded prompt is the record of what actually ran, so it becomes read-only.
|
|
629
|
+
*/
|
|
630
|
+
const TASK_CONTENT_FIELDS = [
|
|
631
|
+
"title",
|
|
632
|
+
"description",
|
|
633
|
+
"prompt"
|
|
634
|
+
];
|
|
635
|
+
/** Whether an update patch touches any task-content field. */
|
|
636
|
+
function hasContentPatch(patch) {
|
|
637
|
+
return TASK_CONTENT_FIELDS.some((field) => field in patch);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Whether a task's content may still be edited: the task must be on-board
|
|
641
|
+
* (not archived) and must never have started executing. Fail-closed: a
|
|
642
|
+
* running, settled, or cancelled-before-launch task keeps its content fixed.
|
|
643
|
+
*/
|
|
644
|
+
function canEditTaskContent(task) {
|
|
645
|
+
return task.archivedAt === void 0 && task.status !== "running" && task.executions.length === 0;
|
|
646
|
+
}
|
|
618
647
|
/** Keep an unknown permission string from entering the ledger. */
|
|
619
648
|
function normalizePermission(current, value) {
|
|
620
649
|
if (value === void 0) return void 0;
|
|
@@ -639,6 +668,11 @@ function applyUpdateTask(tasks, id, patch, now) {
|
|
|
639
668
|
...patch,
|
|
640
669
|
updatedAt: now
|
|
641
670
|
};
|
|
671
|
+
for (const field of TASK_CONTENT_FIELDS) {
|
|
672
|
+
if (!(field in patch)) continue;
|
|
673
|
+
const value = patch[field];
|
|
674
|
+
next[field] = value === void 0 ? task[field] : value.trim();
|
|
675
|
+
}
|
|
642
676
|
if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
|
|
643
677
|
if (mode !== void 0 || "mode" in patch) next.mode = mode;
|
|
644
678
|
if (permission !== void 0 || "permission" in patch) next.permission = permission;
|
|
@@ -1266,6 +1300,8 @@ var HostTaskLedger = class {
|
|
|
1266
1300
|
const task = this.document.tasks.find((task) => task.id === action.taskId);
|
|
1267
1301
|
if (task === void 0) throw new Error("task not found");
|
|
1268
1302
|
if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
|
|
1303
|
+
if (hasContentPatch(action.patch) && !canEditTaskContent(task)) throw new Error("task has already been executed");
|
|
1304
|
+
if ("title" in action.patch && action.patch.title?.trim() === "") throw new Error("title is required");
|
|
1269
1305
|
this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)];
|
|
1270
1306
|
break;
|
|
1271
1307
|
}
|
|
@@ -22,6 +22,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
22
22
|
titleKey: TKey;
|
|
23
23
|
/** Locale key of the line describing what this plugin's settings govern. */
|
|
24
24
|
descriptionKey: TKey;
|
|
25
|
+
/** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
|
|
26
|
+
descriptionNode?: ReactNode;
|
|
25
27
|
/** The card's form state: availability, writability, and what a save would do. */
|
|
26
28
|
state: CardShell;
|
|
27
29
|
/** Write every staged edit. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BoardController } from '../../core/controller.ts';
|
|
2
|
+
import type { TaskRecord } from '../../core/tasks.ts';
|
|
3
|
+
/** Edit-task form overlay. */
|
|
4
|
+
export declare function EditTaskModal({ controller, task, onClose }: {
|
|
5
|
+
controller: BoardController;
|
|
6
|
+
task: TaskRecord;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
@@ -2,7 +2,6 @@ import type { TaskRecord } from '../../core/tasks.ts';
|
|
|
2
2
|
/** Compact relative/absolute time label. */
|
|
3
3
|
export declare function formatHostTimestamp(ms: number, timeZone?: string): string;
|
|
4
4
|
export declare function formatTime(ms: number, timeZone?: string): string;
|
|
5
|
-
/** One card in a column. */
|
|
6
5
|
declare function TaskCardInner({ task, pending, timeZone, onClick }: {
|
|
7
6
|
task: TaskRecord;
|
|
8
7
|
pending: boolean;
|
|
@@ -40,7 +40,10 @@ export declare const zh: {
|
|
|
40
40
|
'new.cancel': string;
|
|
41
41
|
'new.required': string;
|
|
42
42
|
'detail.title': string;
|
|
43
|
+
'detail.edit': string;
|
|
43
44
|
'detail.close': string;
|
|
45
|
+
'edit.title': string;
|
|
46
|
+
'edit.save': string;
|
|
44
47
|
'detail.prompt': string;
|
|
45
48
|
'detail.description': string;
|
|
46
49
|
'detail.execution': string;
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Board controller: the single owner of task-ledger state and view state.
|
|
3
3
|
*
|
|
4
4
|
* In production it projects the Host ledger and submits confirmed actions;
|
|
5
|
-
* the legacy store seam remains for v1 migration tests.
|
|
6
|
-
*
|
|
5
|
+
* the legacy store seam remains for v1 migration tests. The board closes
|
|
6
|
+
* only on explicit user navigation, never implicitly on session-list churn.
|
|
7
7
|
* Framework-free (structural runtime faces) so the whole orchestration is
|
|
8
8
|
* unit-testable with fakes.
|
|
9
9
|
*
|
|
@@ -125,7 +125,15 @@ export declare class BoardController {
|
|
|
125
125
|
createTask(input: NewTaskInput): TaskRecord | undefined;
|
|
126
126
|
/** Create through the Host and expose the task only after confirmation. */
|
|
127
127
|
createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined>;
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Apply an editable-field patch (task content + execution targets).
|
|
130
|
+
* Host-backed: the Host ledger owns the fail-closed checks (the content of
|
|
131
|
+
* an executed task is read-only) and confirms the mutation, so the resolved
|
|
132
|
+
* value reflects whether the Host accepted it; the legacy in-memory path
|
|
133
|
+
* applies and persists synchronously.
|
|
134
|
+
* @returns true when the patch was accepted by the authority.
|
|
135
|
+
*/
|
|
136
|
+
updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean>;
|
|
129
137
|
/**
|
|
130
138
|
* Replace (a part of) the picker option sets the UI feeds (workspace list
|
|
131
139
|
* and agent-preset roster come from the runtime, not the ledger).
|
|
@@ -180,9 +188,17 @@ export declare class BoardController {
|
|
|
180
188
|
runTask(id: string): Promise<boolean>;
|
|
181
189
|
/** Re-run a settled task through the Host (the Host replans and executes). */
|
|
182
190
|
rerunTask(id: string): Promise<void>;
|
|
183
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Session-list notifications fire for all kinds of incidental churn
|
|
193
|
+
* (background navigation, the Host runner creating and selecting a fresh
|
|
194
|
+
* execution session, settlement, other plugins), so closing on `current`
|
|
195
|
+
* changes would evict the board without the user asking. The board closes
|
|
196
|
+
* only on explicit user navigation: a sidebar session/workspace row click
|
|
197
|
+
* (board-mount onClickSidebarRow) or the board's own actions
|
|
198
|
+
* (openSession / close). Keeping the hook preserves the subscription
|
|
199
|
+
* contract for future listeners.
|
|
200
|
+
*/
|
|
184
201
|
private onSessionsChanged;
|
|
185
|
-
private lastCurrent;
|
|
186
202
|
private persistAndNotify;
|
|
187
203
|
private commitRemote;
|
|
188
204
|
private performRemote;
|
|
@@ -4,13 +4,29 @@
|
|
|
4
4
|
* fresh updatedAt. Pure ledger transition (no persistence or notify — the
|
|
5
5
|
* controller orchestrates those).
|
|
6
6
|
*
|
|
7
|
-
* An explicit `undefined` in the patch clears
|
|
8
|
-
* back to the runtime default);
|
|
9
|
-
*
|
|
7
|
+
* An explicit `undefined` in the patch clears an execution-target field
|
|
8
|
+
* (the task falls back to the runtime default); content fields are trimmed
|
|
9
|
+
* and never cleared. An unknown permission string is ignored so stale UI
|
|
10
|
+
* can never persist a value the execution service rejects.
|
|
10
11
|
*/
|
|
11
12
|
import { type TaskRecord } from '../tasks.ts';
|
|
12
13
|
/** Editable fields on a task (the update patch surface). */
|
|
13
14
|
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>;
|
|
15
|
+
/**
|
|
16
|
+
* The fields that edit the task's content (what the user reads and what the
|
|
17
|
+
* next execution sends). Unlike the execution targets they stay editable only
|
|
18
|
+
* while the task has never started executing — after the first run the
|
|
19
|
+
* recorded prompt is the record of what actually ran, so it becomes read-only.
|
|
20
|
+
*/
|
|
21
|
+
export declare const TASK_CONTENT_FIELDS: readonly ["title", "description", "prompt"];
|
|
22
|
+
/** Whether an update patch touches any task-content field. */
|
|
23
|
+
export declare function hasContentPatch(patch: TaskUpdatePatch): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a task's content may still be edited: the task must be on-board
|
|
26
|
+
* (not archived) and must never have started executing. Fail-closed: a
|
|
27
|
+
* running, settled, or cancelled-before-launch task keeps its content fixed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function canEditTaskContent(task: TaskRecord): boolean;
|
|
14
30
|
/**
|
|
15
31
|
* Apply an update across the ledger. Tasks that do not match the id are left
|
|
16
32
|
* untouched; the matched task receives the patch plus a fresh updatedAt.
|
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.3.
|
|
4
|
+
"version": "0.3.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
39
39
|
titleKey: TKey
|
|
40
40
|
/** Locale key of the line describing what this plugin's settings govern. */
|
|
41
41
|
descriptionKey: TKey
|
|
42
|
+
/** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
|
|
43
|
+
descriptionNode?: ReactNode
|
|
42
44
|
/** The card's form state: availability, writability, and what a save would do. */
|
|
43
45
|
state: CardShell
|
|
44
46
|
/** Write every staged edit. */
|
|
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
87
89
|
<div className={css.headerStatic}>
|
|
88
90
|
<span className={css.headText}>
|
|
89
91
|
<span className={css.name} title={title}>{title}</span>
|
|
90
|
-
<span className={css.description} title={description}>{description}</span>
|
|
92
|
+
<span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
|
|
91
93
|
</span>
|
|
92
94
|
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
93
95
|
</div>
|
|
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
102
104
|
>
|
|
103
105
|
<span className={css.headText}>
|
|
104
106
|
<span className={css.name} title={title}>{title}</span>
|
|
105
|
-
<span className={css.description} title={description}>{description}</span>
|
|
107
|
+
<span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
|
|
106
108
|
</span>
|
|
107
109
|
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
108
110
|
<svg
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit-task modal: title + description + the prompt the next execution will
|
|
3
|
+
* send, pre-filled from the task. Shown only for tasks that have never
|
|
4
|
+
* started executing (the detail view gates on canEditTaskContent); the Host
|
|
5
|
+
* still re-checks at submit, so a task that started running while the modal
|
|
6
|
+
* was open fails closed and the error surfaces here.
|
|
7
|
+
*/
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import type { BoardController } from '../../core/controller.ts'
|
|
10
|
+
import type { TaskRecord } from '../../core/tasks.ts'
|
|
11
|
+
import { t } from '../locales.ts'
|
|
12
|
+
import css from '../board.module.css'
|
|
13
|
+
|
|
14
|
+
/** Edit-task form overlay. */
|
|
15
|
+
export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
|
|
16
|
+
const [title, setTitle] = useState(task.title)
|
|
17
|
+
const [description, setDescription] = useState(task.description)
|
|
18
|
+
const [prompt, setPrompt] = useState(task.prompt)
|
|
19
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
20
|
+
const [pending, setPending] = useState(false)
|
|
21
|
+
|
|
22
|
+
const submit = async (): Promise<void> => {
|
|
23
|
+
if (title.trim() === '') {
|
|
24
|
+
setError(t('new.required'))
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
setPending(true)
|
|
28
|
+
// The Host confirms the mutation (and its fail-closed checks); only a
|
|
29
|
+
// confirmed save closes the modal.
|
|
30
|
+
if (await controller.updateTask(task.id, { title, description, prompt })) {
|
|
31
|
+
onClose()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
setPending(false)
|
|
35
|
+
setError(controller.getSnapshot().transportError ?? t('new.required'))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
|
|
40
|
+
<form
|
|
41
|
+
className={css.modal}
|
|
42
|
+
role="dialog"
|
|
43
|
+
aria-label={t('edit.title')}
|
|
44
|
+
onSubmit={event => { event.preventDefault(); void submit() }}
|
|
45
|
+
>
|
|
46
|
+
<h2 className={css.modalTitle}>{t('edit.title')}</h2>
|
|
47
|
+
|
|
48
|
+
<label className={css.field}>
|
|
49
|
+
<span className={css.fieldLabel}>{t('new.title')}</span>
|
|
50
|
+
<input
|
|
51
|
+
className={css.input}
|
|
52
|
+
value={title}
|
|
53
|
+
autoFocus
|
|
54
|
+
placeholder={t('new.titlePlaceholder')}
|
|
55
|
+
onChange={event => { setTitle(event.target.value); setError(undefined) }}
|
|
56
|
+
/>
|
|
57
|
+
</label>
|
|
58
|
+
|
|
59
|
+
<label className={css.field}>
|
|
60
|
+
<span className={css.fieldLabel}>{t('new.description')}</span>
|
|
61
|
+
<textarea
|
|
62
|
+
className={css.input}
|
|
63
|
+
rows={3}
|
|
64
|
+
value={description}
|
|
65
|
+
placeholder={t('new.descriptionPlaceholder')}
|
|
66
|
+
onChange={event => { setDescription(event.target.value) }}
|
|
67
|
+
/>
|
|
68
|
+
</label>
|
|
69
|
+
|
|
70
|
+
<label className={css.field}>
|
|
71
|
+
<span className={css.fieldLabel}>{t('new.prompt')}</span>
|
|
72
|
+
<textarea
|
|
73
|
+
className={css.input}
|
|
74
|
+
rows={4}
|
|
75
|
+
value={prompt}
|
|
76
|
+
placeholder={t('new.promptPlaceholder')}
|
|
77
|
+
onChange={event => { setPrompt(event.target.value) }}
|
|
78
|
+
/>
|
|
79
|
+
</label>
|
|
80
|
+
|
|
81
|
+
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
82
|
+
|
|
83
|
+
<footer className={css.modalFooter}>
|
|
84
|
+
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
85
|
+
{t('new.cancel')}
|
|
86
|
+
</button>
|
|
87
|
+
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
88
|
+
{t('edit.save')}
|
|
89
|
+
</button>
|
|
90
|
+
</footer>
|
|
91
|
+
</form>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { memo, useCallback, useEffect, useState } from 'react'
|
|
7
7
|
import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
|
|
8
|
-
import { COLUMNS, type TaskRecord } from '../../core/tasks.ts'
|
|
8
|
+
import { COLUMNS, canMoveManually, type TaskRecord } from '../../core/tasks.ts'
|
|
9
9
|
import { t } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
11
11
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
@@ -124,8 +124,27 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
124
124
|
) : (
|
|
125
125
|
COLUMNS.map(column => {
|
|
126
126
|
const tasks = visible.filter(task => task.status === column.status)
|
|
127
|
+
const isManualDropTarget = column.status === 'backlog' || column.status === 'todo'
|
|
127
128
|
return (
|
|
128
|
-
<section
|
|
129
|
+
<section
|
|
130
|
+
key={column.status}
|
|
131
|
+
className={css.column}
|
|
132
|
+
data-status={column.status}
|
|
133
|
+
data-dsh-part="column"
|
|
134
|
+
onDragOver={isManualDropTarget ? (event) => {
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
event.dataTransfer.dropEffect = 'move'
|
|
137
|
+
} : undefined}
|
|
138
|
+
onDrop={isManualDropTarget ? (event) => {
|
|
139
|
+
event.preventDefault()
|
|
140
|
+
const taskId = event.dataTransfer.getData('text/plain')
|
|
141
|
+
if (!taskId) return
|
|
142
|
+
const dropped = snapshot.tasks.find(t => t.id === taskId)
|
|
143
|
+
if (dropped && canMoveManually(dropped.status, column.status) && dropped.status !== column.status) {
|
|
144
|
+
controller.moveTask(taskId, column.status)
|
|
145
|
+
}
|
|
146
|
+
} : undefined}
|
|
147
|
+
>
|
|
129
148
|
<header className={css.columnHeader}>
|
|
130
149
|
<span className={css.statusDot} data-status={column.status} aria-hidden="true" />
|
|
131
150
|
<h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
|
|
@@ -37,11 +37,12 @@ export function formatTime(ms: number, timeZone?: string): string {
|
|
|
37
37
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** One card in a column. */
|
|
41
40
|
function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord; pending: boolean; timeZone?: string; onClick: () => void }) {
|
|
42
41
|
const latest = task.executions[task.executions.length - 1]
|
|
43
42
|
const runs = task.executions.length
|
|
44
43
|
const archived = task.archivedAt !== undefined
|
|
44
|
+
const isDraggable = !archived && task.status !== 'running' && !pending
|
|
45
|
+
|
|
45
46
|
return (
|
|
46
47
|
<button
|
|
47
48
|
type="button"
|
|
@@ -49,6 +50,11 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
|
|
|
49
50
|
data-status={archived ? 'archived' : task.status}
|
|
50
51
|
data-dsh-part="card"
|
|
51
52
|
data-pending={pending || undefined}
|
|
53
|
+
draggable={isDraggable}
|
|
54
|
+
onDragStart={isDraggable ? (event) => {
|
|
55
|
+
event.dataTransfer.setData('text/plain', task.id)
|
|
56
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
57
|
+
} : undefined}
|
|
52
58
|
onClick={onClick}
|
|
53
59
|
title={task.description !== '' ? task.description : task.title}
|
|
54
60
|
>
|
|
@@ -8,10 +8,12 @@ import { useEffect, useState } from 'react'
|
|
|
8
8
|
import type { BoardController } from '../../core/controller.ts'
|
|
9
9
|
import { isValidCron } from '../../core/schedule.ts'
|
|
10
10
|
import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
11
|
+
import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
|
|
11
12
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
12
13
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
13
14
|
import css from '../board.module.css'
|
|
14
15
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
16
|
+
import { EditTaskModal } from './EditTaskModal.tsx'
|
|
15
17
|
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
16
18
|
import { STATUS_KEY } from './status-key.ts'
|
|
17
19
|
|
|
@@ -232,10 +234,13 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
232
234
|
/** Task detail overlay. */
|
|
233
235
|
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
234
236
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
237
|
+
const [showEdit, setShowEdit] = useState(false)
|
|
235
238
|
|
|
236
239
|
// Keep the overlay in sync if the task record changes underneath.
|
|
237
240
|
const [latest, setLatest] = useState(task)
|
|
238
241
|
useEffect(() => { setLatest(task) }, [task])
|
|
242
|
+
// A re-used overlay instance must not carry an edit session across tasks.
|
|
243
|
+
useEffect(() => { setShowEdit(false) }, [task.id])
|
|
239
244
|
const current = latest
|
|
240
245
|
const snapshot = controller.getSnapshot()
|
|
241
246
|
const running = current.status === 'running'
|
|
@@ -328,6 +333,16 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
328
333
|
|
|
329
334
|
<footer className={css.detailFooter}>
|
|
330
335
|
{!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
|
|
336
|
+
{!archived && canEditTaskContent(current) && (
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
className={css.ghostButton}
|
|
340
|
+
disabled={pending}
|
|
341
|
+
onClick={() => { setShowEdit(true) }}
|
|
342
|
+
>
|
|
343
|
+
{t('detail.edit')}
|
|
344
|
+
</button>
|
|
345
|
+
)}
|
|
331
346
|
{!archived && (
|
|
332
347
|
<button
|
|
333
348
|
type="button"
|
|
@@ -395,6 +410,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
395
410
|
}}
|
|
396
411
|
/>
|
|
397
412
|
)}
|
|
413
|
+
|
|
414
|
+
{showEdit && !archived && canEditTaskContent(current) && (
|
|
415
|
+
<EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
|
|
416
|
+
)}
|
|
398
417
|
</div>
|
|
399
418
|
)
|
|
400
419
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -41,7 +41,10 @@ export const zh = {
|
|
|
41
41
|
'new.cancel': '取消',
|
|
42
42
|
'new.required': '标题不能为空',
|
|
43
43
|
'detail.title': '任务详情',
|
|
44
|
+
'detail.edit': '编辑',
|
|
44
45
|
'detail.close': '关闭',
|
|
46
|
+
'edit.title': '编辑任务',
|
|
47
|
+
'edit.save': '保存',
|
|
45
48
|
'detail.prompt': '执行 Prompt',
|
|
46
49
|
'detail.description': '描述',
|
|
47
50
|
'detail.execution': '执行记录',
|
|
@@ -164,7 +167,10 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
164
167
|
'new.cancel': 'Cancel',
|
|
165
168
|
'new.required': 'Title is required',
|
|
166
169
|
'detail.title': 'Task Detail',
|
|
170
|
+
'detail.edit': 'Edit',
|
|
167
171
|
'detail.close': 'Close',
|
|
172
|
+
'edit.title': 'Edit Task',
|
|
173
|
+
'edit.save': 'Save',
|
|
168
174
|
'detail.prompt': 'Run Prompt',
|
|
169
175
|
'detail.description': 'Description',
|
|
170
176
|
'detail.execution': 'Execution History',
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
.header {
|
|
25
25
|
appearance: none;
|
|
26
26
|
width: 100%;
|
|
27
|
+
/* The suite does not force a global border-box, so width:100% + padding
|
|
28
|
+
would stretch the row 32px past the card and push the trailing pending
|
|
29
|
+
badge (last flex item) outside the card border. */
|
|
30
|
+
box-sizing: border-box;
|
|
27
31
|
font: inherit;
|
|
28
32
|
color: inherit;
|
|
29
33
|
text-align: left;
|
|
@@ -44,6 +48,7 @@
|
|
|
44
48
|
|
|
45
49
|
.headerStatic {
|
|
46
50
|
width: 100%;
|
|
51
|
+
box-sizing: border-box;
|
|
47
52
|
border-radius: 12px;
|
|
48
53
|
align-items: center;
|
|
49
54
|
gap: 12px;
|
package/src/core/controller.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Board controller: the single owner of task-ledger state and view state.
|
|
3
3
|
*
|
|
4
4
|
* In production it projects the Host ledger and submits confirmed actions;
|
|
5
|
-
* the legacy store seam remains for v1 migration tests.
|
|
6
|
-
*
|
|
5
|
+
* the legacy store seam remains for v1 migration tests. The board closes
|
|
6
|
+
* only on explicit user navigation, never implicitly on session-list churn.
|
|
7
7
|
* Framework-free (structural runtime faces) so the whole orchestration is
|
|
8
8
|
* unit-testable with fakes.
|
|
9
9
|
*
|
|
@@ -108,11 +108,6 @@ function randomUuid(): string {
|
|
|
108
108
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** Read the current selection off a session-list snapshot (structural). */
|
|
112
|
-
function currentOf(sessions: SessionsControllerFace): string | undefined {
|
|
113
|
-
return sessions.list.getSnapshot().current
|
|
114
|
-
}
|
|
115
|
-
|
|
116
111
|
function messageOf(error: unknown): string {
|
|
117
112
|
return error instanceof Error ? error.message : String(error)
|
|
118
113
|
}
|
|
@@ -205,10 +200,11 @@ export class BoardController {
|
|
|
205
200
|
|
|
206
201
|
openBoard(): void {
|
|
207
202
|
if (this.boardOpen) return
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
203
|
+
// The board stays open until the user explicitly closes it: by clicking
|
|
204
|
+
// the sidebar entry again, a sidebar session/workspace row, or a board
|
|
205
|
+
// action (openSession / close). Session-list churn (background
|
|
206
|
+
// navigation, the Host runner selecting a fresh execution session,
|
|
207
|
+
// settlement, other plugins) never evicts the board.
|
|
212
208
|
this.boardOpen = true
|
|
213
209
|
this.notify()
|
|
214
210
|
}
|
|
@@ -273,13 +269,21 @@ export class BoardController {
|
|
|
273
269
|
: undefined
|
|
274
270
|
}
|
|
275
271
|
|
|
276
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Apply an editable-field patch (task content + execution targets).
|
|
274
|
+
* Host-backed: the Host ledger owns the fail-closed checks (the content of
|
|
275
|
+
* an executed task is read-only) and confirms the mutation, so the resolved
|
|
276
|
+
* value reflects whether the Host accepted it; the legacy in-memory path
|
|
277
|
+
* applies and persists synchronously.
|
|
278
|
+
* @returns true when the patch was accepted by the authority.
|
|
279
|
+
*/
|
|
280
|
+
async updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean> {
|
|
277
281
|
if (this.deps.transport !== undefined) {
|
|
278
|
-
|
|
279
|
-
return
|
|
282
|
+
return await this.commitRemote({ kind: 'update', taskId: id, patch }, id)
|
|
280
283
|
}
|
|
281
284
|
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
|
|
282
285
|
this.persistAndNotify()
|
|
286
|
+
return true
|
|
283
287
|
}
|
|
284
288
|
|
|
285
289
|
/**
|
|
@@ -420,16 +424,20 @@ export class BoardController {
|
|
|
420
424
|
|
|
421
425
|
// --- internals ---------------------------------------------------------------
|
|
422
426
|
|
|
423
|
-
/**
|
|
427
|
+
/**
|
|
428
|
+
* Session-list notifications fire for all kinds of incidental churn
|
|
429
|
+
* (background navigation, the Host runner creating and selecting a fresh
|
|
430
|
+
* execution session, settlement, other plugins), so closing on `current`
|
|
431
|
+
* changes would evict the board without the user asking. The board closes
|
|
432
|
+
* only on explicit user navigation: a sidebar session/workspace row click
|
|
433
|
+
* (board-mount onClickSidebarRow) or the board's own actions
|
|
434
|
+
* (openSession / close). Keeping the hook preserves the subscription
|
|
435
|
+
* contract for future listeners.
|
|
436
|
+
*/
|
|
424
437
|
private onSessionsChanged(): void {
|
|
425
|
-
|
|
426
|
-
const current = currentOf(this.deps.sessions)
|
|
427
|
-
if (current !== this.lastCurrent) this.closeBoard()
|
|
428
|
-
this.lastCurrent = current
|
|
438
|
+
// Intentionally empty: never close the board implicitly.
|
|
429
439
|
}
|
|
430
440
|
|
|
431
|
-
private lastCurrent: string | undefined = undefined
|
|
432
|
-
|
|
433
441
|
private persistAndNotify(): void {
|
|
434
442
|
if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
|
|
435
443
|
this.notify()
|