@linxin666/dsh-client-ui-task-board 0.1.17 → 0.1.18
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 +3 -3
- package/README.zh.md +3 -3
- package/lib/client.js +377 -115
- package/lib/client.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/locales.d.ts +7 -0
- package/lib/types/client/schedule-presets.d.ts +10 -0
- package/lib/types/core/controller.d.ts +27 -0
- package/lib/types/core/execution.d.ts +16 -0
- package/lib/types/core/scheduler.d.ts +7 -0
- package/lib/types/core/tasks.d.ts +16 -0
- package/lib/types/core/use-cases/task-archive.d.ts +22 -0
- package/lib/types/core/use-cases/task-create.d.ts +1 -6
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/board/NewTaskModal.tsx +68 -0
- package/src/client/board/TaskBoard.tsx +48 -19
- package/src/client/board/TaskDetail.tsx +27 -8
- package/src/client/index.ts +3 -0
- package/src/client/locales.ts +14 -0
- package/src/client/schedule-presets.ts +13 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/core/controller.ts +54 -0
- package/src/core/execution.ts +41 -2
- package/src/core/scheduler.ts +15 -0
- package/src/core/store.ts +1 -0
- package/src/core/tasks.ts +15 -0
- package/src/core/use-cases/task-archive.ts +48 -0
- package/src/core/use-cases/task-create.ts +12 -3
|
@@ -28,6 +28,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
28
28
|
onSave: () => void;
|
|
29
29
|
/** Drop every staged edit. */
|
|
30
30
|
onDiscard: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Render the controls expanded on arrival (still collapsible). Defaults to
|
|
33
|
+
* true: promoted first-level sections show their settings immediately.
|
|
34
|
+
*/
|
|
35
|
+
defaultOpen?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Render without the collapse affordance: a static header and an always
|
|
38
|
+
* visible body. Used by first-level settings sections whose nav entry
|
|
39
|
+
* already provides the selection.
|
|
40
|
+
*/
|
|
41
|
+
alwaysOpen?: boolean;
|
|
31
42
|
/** The plugin's controls. */
|
|
32
43
|
children: ReactNode;
|
|
33
44
|
}
|
|
@@ -13,6 +13,10 @@ export declare const zh: {
|
|
|
13
13
|
'board.search': string;
|
|
14
14
|
'board.empty': string;
|
|
15
15
|
'board.filterAll': string;
|
|
16
|
+
'board.archive': string;
|
|
17
|
+
'board.archiveView': string;
|
|
18
|
+
'board.backToBoard': string;
|
|
19
|
+
'archive.empty': string;
|
|
16
20
|
'board.status': string;
|
|
17
21
|
'board.status.backlog': string;
|
|
18
22
|
'board.status.todo': string;
|
|
@@ -40,6 +44,9 @@ export declare const zh: {
|
|
|
40
44
|
'detail.run': string;
|
|
41
45
|
'detail.rerun': string;
|
|
42
46
|
'detail.delete': string;
|
|
47
|
+
'detail.archive': string;
|
|
48
|
+
'detail.restore': string;
|
|
49
|
+
'detail.archivedAt': string;
|
|
43
50
|
'detail.viewSession': string;
|
|
44
51
|
'detail.noSession': string;
|
|
45
52
|
'detail.executionStarted': string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scheduled-run presets for the schedule editors (the new-task dialog
|
|
3
|
+
* and the task detail panel): cron expression → locale label.
|
|
4
|
+
*/
|
|
5
|
+
import type { TaskBoardKey } from './locales.ts';
|
|
6
|
+
/** Common scheduled-run presets (cron → locale label). */
|
|
7
|
+
export declare const SCHEDULE_PRESETS: ReadonlyArray<{
|
|
8
|
+
cron: string;
|
|
9
|
+
label: TaskBoardKey;
|
|
10
|
+
}>;
|
|
@@ -64,6 +64,8 @@ export interface ExecutionOptionsSnapshot {
|
|
|
64
64
|
export interface ControllerSnapshot {
|
|
65
65
|
tasks: readonly TaskRecord[];
|
|
66
66
|
boardOpen: boolean;
|
|
67
|
+
/** True when the board shows the archive view instead of the columns. */
|
|
68
|
+
archiveView: boolean;
|
|
67
69
|
selectedTaskId: string | undefined;
|
|
68
70
|
/** Picker option sets (workspace list + agent-preset roster). */
|
|
69
71
|
executionOptions: ExecutionOptionsSnapshot;
|
|
@@ -78,6 +80,7 @@ export declare class BoardController {
|
|
|
78
80
|
private readonly deps;
|
|
79
81
|
private tasks;
|
|
80
82
|
private boardOpen;
|
|
83
|
+
private archiveView;
|
|
81
84
|
private selectedTaskId;
|
|
82
85
|
private executionOptions;
|
|
83
86
|
private listeners;
|
|
@@ -95,6 +98,12 @@ export declare class BoardController {
|
|
|
95
98
|
openBoard(): void;
|
|
96
99
|
closeBoard(): void;
|
|
97
100
|
toggleBoard(): void;
|
|
101
|
+
/**
|
|
102
|
+
* Switch between the kanban columns and the archive view. Leaving the
|
|
103
|
+
* archive view with an archived task still selected closes the selection —
|
|
104
|
+
* the detail overlay must not linger over a task that is off-board.
|
|
105
|
+
*/
|
|
106
|
+
toggleArchiveView(): void;
|
|
98
107
|
openTask(id: string): void;
|
|
99
108
|
closeTask(): void;
|
|
100
109
|
createTask(input: NewTaskInput): TaskRecord | undefined;
|
|
@@ -106,6 +115,15 @@ export declare class BoardController {
|
|
|
106
115
|
setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void;
|
|
107
116
|
moveTask(id: string, status: TaskStatus): void;
|
|
108
117
|
deleteTask(id: string): void;
|
|
118
|
+
/**
|
|
119
|
+
* Archive a settled task (done/failed). Running or on-board-unsettled
|
|
120
|
+
* tasks are refused so the runner keeps exclusive ownership of their
|
|
121
|
+
* lifecycle.
|
|
122
|
+
* @returns true when applied.
|
|
123
|
+
*/
|
|
124
|
+
archiveTask(id: string): boolean;
|
|
125
|
+
/** Restore an archived task back onto the board (same status column). */
|
|
126
|
+
restoreTask(id: string): boolean;
|
|
109
127
|
/**
|
|
110
128
|
* Update a task's schedule rule. A blank or invalid cron expression is
|
|
111
129
|
* rejected (returns false, state untouched). When the rule ends up enabled
|
|
@@ -125,6 +143,15 @@ export declare class BoardController {
|
|
|
125
143
|
* schedule rule (it was deleted mid-tick, for example).
|
|
126
144
|
*/
|
|
127
145
|
applyScheduleNextRun(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void;
|
|
146
|
+
/**
|
|
147
|
+
* Reload the ledger from the persisted store without notifying subscribers.
|
|
148
|
+
* The scheduler calls this before every tick so a task deleted in another
|
|
149
|
+
* tab (or a stale in-memory copy) can never be fired from this tab: the
|
|
150
|
+
* fire decision and the subsequent roll-forward both run on the freshest
|
|
151
|
+
* persisted truth. Deliberately silent — same-origin external changes still
|
|
152
|
+
* reach subscribers through the storage-event subscription.
|
|
153
|
+
*/
|
|
154
|
+
reloadFromStore(): void;
|
|
128
155
|
/**
|
|
129
156
|
* Jump to an execution's session transcript. Selecting the session changes
|
|
130
157
|
* `current`, which closes the board (the conversation view takes over).
|
|
@@ -133,6 +133,15 @@ export type ExecutionEvent = {
|
|
|
133
133
|
*/
|
|
134
134
|
export declare class ExecutionService {
|
|
135
135
|
private readonly env;
|
|
136
|
+
/**
|
|
137
|
+
* In-flight preset switches keyed by `sessionId\u0000mode`. The session
|
|
138
|
+
* list mirror can lag behind a just-applied switch, so concurrent runs
|
|
139
|
+
* against one blank session would otherwise each issue the same select
|
|
140
|
+
* RPC (an amplified storm); sharing one in-flight call keeps the wire to
|
|
141
|
+
* a single request. The settled entry is forgotten so a later run can
|
|
142
|
+
* switch again.
|
|
143
|
+
*/
|
|
144
|
+
private readonly presetSwitches;
|
|
136
145
|
/** @param env - the runtime faces (real or fake). */
|
|
137
146
|
constructor(env: ExecutionEnvironment);
|
|
138
147
|
run(task: TaskRecord, execution: ExecutionRecord, onEvent: (event: ExecutionEvent) => void): Promise<void>;
|
|
@@ -143,6 +152,13 @@ export declare class ExecutionService {
|
|
|
143
152
|
* the wire refuses.
|
|
144
153
|
*/
|
|
145
154
|
private applyMode;
|
|
155
|
+
/**
|
|
156
|
+
* One in-flight `select` per (session, preset): concurrent runs against
|
|
157
|
+
* the same blank session share the same wire call instead of each issuing
|
|
158
|
+
* a duplicate RPC. The entry is removed once the call settles, so a later
|
|
159
|
+
* run (after a shared failure, say) issues a fresh switch.
|
|
160
|
+
*/
|
|
161
|
+
private switchPreset;
|
|
146
162
|
/**
|
|
147
163
|
* Apply the task-pinned permission preset through the `/permission <id>`
|
|
148
164
|
* slash command. No-op when the task pins none; fails the run when the
|
|
@@ -3,6 +3,13 @@ import type { TaskRecord } from './tasks.ts';
|
|
|
3
3
|
export interface SchedulerDeps {
|
|
4
4
|
/** Read the current task ledger (the controller snapshot). */
|
|
5
5
|
tasks(): readonly TaskRecord[];
|
|
6
|
+
/**
|
|
7
|
+
* Re-sync the task ledger from its persisted source before the tick decides
|
|
8
|
+
* what is due. The wiring passes the controller's store reload so a task
|
|
9
|
+
* deleted elsewhere can never be fired from a stale in-memory copy.
|
|
10
|
+
* Optional: absent in pure in-memory harnesses.
|
|
11
|
+
*/
|
|
12
|
+
refresh?: () => void;
|
|
6
13
|
/** Clock; defaults to Date.now in the controller wiring. */
|
|
7
14
|
now(): number;
|
|
8
15
|
/** Trigger one task's real execution (the controller's runTask); resolves
|
|
@@ -75,7 +75,14 @@ export interface TaskRecord {
|
|
|
75
75
|
* `/permission <id>` slash command; absent leaves the session default.
|
|
76
76
|
*/
|
|
77
77
|
permission?: TaskPermission;
|
|
78
|
+
/**
|
|
79
|
+
* When the task was archived (ms epoch). Archived tasks keep their status
|
|
80
|
+
* and execution history but leave the main board; absent means on-board.
|
|
81
|
+
*/
|
|
82
|
+
archivedAt?: number;
|
|
78
83
|
}
|
|
84
|
+
/** Statuses a settled task may be archived from. */
|
|
85
|
+
export declare const ARCHIVABLE_STATUSES: readonly TaskStatus[];
|
|
79
86
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
80
87
|
export declare const TASK_PERMISSIONS: readonly ["read-only", "workspace-write", "danger-full-access"];
|
|
81
88
|
/** One permission preset id. */
|
|
@@ -93,6 +100,15 @@ export interface NewTaskInput {
|
|
|
93
100
|
mode?: string;
|
|
94
101
|
/** Permission preset applied to the execution session; absent = session default. */
|
|
95
102
|
permission?: TaskPermission;
|
|
103
|
+
/**
|
|
104
|
+
* Optional scheduled-run rule requested at creation time (the new-task
|
|
105
|
+
* dialog): an enable flag plus a 5-field cron expression. The create use
|
|
106
|
+
* case arms it only when enabled and the expression is valid.
|
|
107
|
+
*/
|
|
108
|
+
schedule?: {
|
|
109
|
+
enabled: boolean;
|
|
110
|
+
cron: string;
|
|
111
|
+
};
|
|
96
112
|
}
|
|
97
113
|
/** The five kanban columns, in display order. */
|
|
98
114
|
export declare const COLUMNS: readonly {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive/restore task use case: move a settled (done/failed) task off the
|
|
3
|
+
* main board and back. Purely a marker transition — the task keeps its
|
|
4
|
+
* status, execution history and transcript references, so archiving never
|
|
5
|
+
* loses traceability and restoring lands it in the exact column it left.
|
|
6
|
+
*/
|
|
7
|
+
import type { TaskRecord } from '../tasks.ts';
|
|
8
|
+
/** Result of an archive transition. */
|
|
9
|
+
export interface ArchiveTaskResult {
|
|
10
|
+
/** The next ledger. */
|
|
11
|
+
tasks: readonly TaskRecord[];
|
|
12
|
+
/** Whether the archive was applied (false = unknown task / not archivable). */
|
|
13
|
+
archived: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Archive one task: only settled statuses (done/failed) can be archived;
|
|
17
|
+
* a running or not-yet-settled task stays on the board (its runner still
|
|
18
|
+
* owns its lifecycle). Already-archived tasks are a no-op.
|
|
19
|
+
*/
|
|
20
|
+
export declare function applyArchiveTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult;
|
|
21
|
+
/** Restore one task back onto the main board (clears the archive marker). */
|
|
22
|
+
export declare function applyRestoreTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult;
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create-task use case: mint a new task from user input, rejecting a blank
|
|
3
|
-
* title. Pure ledger transition (no persistence or notify — the controller
|
|
4
|
-
* orchestrates those), so it is unit-testable without any runtime face.
|
|
5
|
-
*/
|
|
6
1
|
import { type NewTaskInput, type TaskRecord } from '../tasks.ts';
|
|
7
2
|
/** Result of a create transition: the new task (when accepted) + the next ledger. */
|
|
8
3
|
export interface CreateTaskResult {
|
|
@@ -15,7 +10,7 @@ export interface CreateTaskResult {
|
|
|
15
10
|
* Apply a create against the current ledger. Returns the new task and the
|
|
16
11
|
* appended ledger, or the unchanged ledger when the title is blank.
|
|
17
12
|
* @param tasks - current ledger.
|
|
18
|
-
* @param input - raw user input (title/description/prompt).
|
|
13
|
+
* @param input - raw user input (title/description/prompt + optional schedule).
|
|
19
14
|
* @param now - clock instant (ms epoch).
|
|
20
15
|
* @param id - minted task id.
|
|
21
16
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.18",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -45,6 +45,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
45
45
|
onSave: () => void
|
|
46
46
|
/** Drop every staged edit. */
|
|
47
47
|
onDiscard: () => void
|
|
48
|
+
/**
|
|
49
|
+
* Render the controls expanded on arrival (still collapsible). Defaults to
|
|
50
|
+
* true: promoted first-level sections show their settings immediately.
|
|
51
|
+
*/
|
|
52
|
+
defaultOpen?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Render without the collapse affordance: a static header and an always
|
|
55
|
+
* visible body. Used by first-level settings sections whose nav entry
|
|
56
|
+
* already provides the selection.
|
|
57
|
+
*/
|
|
58
|
+
alwaysOpen?: boolean
|
|
48
59
|
/** The plugin's controls. */
|
|
49
60
|
children: ReactNode
|
|
50
61
|
}
|
|
@@ -55,57 +66,27 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
55
66
|
* @returns the card, or nothing while the namespace is still loading.
|
|
56
67
|
*/
|
|
57
68
|
export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
|
|
58
|
-
const [open, setOpen] = useState(
|
|
59
|
-
const { state } = props
|
|
69
|
+
const [open, setOpen] = useState(props.defaultOpen ?? true)
|
|
70
|
+
const { state, alwaysOpen } = props
|
|
60
71
|
if (!state.available) return null
|
|
61
72
|
const title = props.t(props.titleKey)
|
|
62
73
|
const description = props.t(props.descriptionKey)
|
|
63
74
|
const blocked = !state.dirty || state.invalid || state.saving
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
onClick={() => { setOpen(!open) }}
|
|
78
|
-
>
|
|
79
|
-
<span className={css.headText}>
|
|
80
|
-
<span className={css.name} title={title}>{title}</span>
|
|
81
|
-
<span className={css.description} title={description}>{description}</span>
|
|
82
|
-
</span>
|
|
83
|
-
<svg
|
|
84
|
-
width="14"
|
|
85
|
-
height="14"
|
|
86
|
-
viewBox="0 0 14 14"
|
|
87
|
-
fill="none"
|
|
88
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
89
|
-
className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
|
|
90
|
-
>
|
|
91
|
-
<path
|
|
92
|
-
d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
|
|
93
|
-
fill="currentColor"
|
|
94
|
-
/>
|
|
95
|
-
</svg>
|
|
96
|
-
</button>
|
|
97
|
-
{open
|
|
98
|
-
? (
|
|
99
|
-
<div className={css.body}>
|
|
100
|
-
<p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
|
|
101
|
-
</div>
|
|
102
|
-
)
|
|
103
|
-
: null}
|
|
104
|
-
</li>
|
|
75
|
+
const expanded = alwaysOpen === true || open
|
|
76
|
+
const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
|
|
77
|
+
// With alwaysOpen the nav entry already provides the selection, so the
|
|
78
|
+
// header is a static title row instead of a disclosure button.
|
|
79
|
+
const header = alwaysOpen === true
|
|
80
|
+
? (
|
|
81
|
+
<div className={css.headerStatic}>
|
|
82
|
+
<span className={css.headText}>
|
|
83
|
+
<span className={css.name} title={title}>{title}</span>
|
|
84
|
+
<span className={css.description} title={description}>{description}</span>
|
|
85
|
+
</span>
|
|
86
|
+
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
87
|
+
</div>
|
|
105
88
|
)
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<li className={cardClass}>
|
|
89
|
+
: (
|
|
109
90
|
<button
|
|
110
91
|
type="button"
|
|
111
92
|
className={css.header}
|
|
@@ -132,7 +113,29 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
132
113
|
/>
|
|
133
114
|
</svg>
|
|
134
115
|
</button>
|
|
135
|
-
|
|
116
|
+
)
|
|
117
|
+
// The namespace exists but the Host does not serve it to this client (the
|
|
118
|
+
// official settings allowlist omits third-party namespaces): show a card
|
|
119
|
+
// that explains the gap instead of vanishing, so a missing card never
|
|
120
|
+
// reads as a missing plugin.
|
|
121
|
+
if (!state.exposed) {
|
|
122
|
+
return (
|
|
123
|
+
<li className={cardClass}>
|
|
124
|
+
{header}
|
|
125
|
+
{expanded
|
|
126
|
+
? (
|
|
127
|
+
<div className={css.body}>
|
|
128
|
+
<p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
: null}
|
|
132
|
+
</li>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return (
|
|
136
|
+
<li className={cardClass}>
|
|
137
|
+
{header}
|
|
138
|
+
{expanded
|
|
136
139
|
? (
|
|
137
140
|
<div className={css.body}>
|
|
138
141
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { useEffect, useState } from 'react'
|
|
6
6
|
import type { BoardController } from '../../core/controller.ts'
|
|
7
|
+
import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
|
|
7
8
|
import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
|
|
8
9
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
10
|
+
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
9
11
|
import css from '../board.module.css'
|
|
10
12
|
|
|
11
13
|
/** New-task form overlay. */
|
|
@@ -16,6 +18,9 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
16
18
|
const [workspaceId, setWorkspaceId] = useState('')
|
|
17
19
|
const [mode, setMode] = useState('')
|
|
18
20
|
const [permission, setPermission] = useState('')
|
|
21
|
+
const [scheduleEnabled, setScheduleEnabled] = useState(false)
|
|
22
|
+
const [scheduleCron, setScheduleCron] = useState('')
|
|
23
|
+
const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
|
|
19
24
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
20
25
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
21
26
|
|
|
@@ -27,6 +32,13 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
27
32
|
)
|
|
28
33
|
|
|
29
34
|
const submit = (): void => {
|
|
35
|
+
if (scheduleEnabled) {
|
|
36
|
+
const cron = scheduleCron.trim()
|
|
37
|
+
if (cron === '' || !isValidCron(cron)) {
|
|
38
|
+
setScheduleError(t('detail.schedule.invalid'))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
}
|
|
30
42
|
const task = controller.createTask({
|
|
31
43
|
title,
|
|
32
44
|
description,
|
|
@@ -34,6 +46,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
34
46
|
workspaceId: workspaceId === '' ? undefined : workspaceId,
|
|
35
47
|
mode: mode === '' ? undefined : mode,
|
|
36
48
|
permission: permission === '' ? undefined : permission as TaskPermission,
|
|
49
|
+
schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
|
|
37
50
|
})
|
|
38
51
|
if (task === undefined) {
|
|
39
52
|
setError(t('new.required'))
|
|
@@ -42,6 +55,11 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
42
55
|
onClose()
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
/** Next-run preview for a valid armed cron (creation-time only). */
|
|
59
|
+
const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
|
|
60
|
+
? nextRunAtMs(scheduleCron, Date.now())
|
|
61
|
+
: undefined
|
|
62
|
+
|
|
45
63
|
return (
|
|
46
64
|
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
|
|
47
65
|
<form
|
|
@@ -131,6 +149,56 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
131
149
|
</select>
|
|
132
150
|
</label>
|
|
133
151
|
|
|
152
|
+
<section className={css.detailSection}>
|
|
153
|
+
<h4>{t('detail.schedule')}</h4>
|
|
154
|
+
<label className={css.scheduleToggle}>
|
|
155
|
+
<input
|
|
156
|
+
type="checkbox"
|
|
157
|
+
checked={scheduleEnabled}
|
|
158
|
+
onChange={event => {
|
|
159
|
+
setScheduleEnabled(event.target.checked)
|
|
160
|
+
if (!event.target.checked) setScheduleError(undefined)
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
<span>{t('detail.schedule.enable')}</span>
|
|
164
|
+
</label>
|
|
165
|
+
{scheduleEnabled && (
|
|
166
|
+
<>
|
|
167
|
+
<div className={css.scheduleRow}>
|
|
168
|
+
<input
|
|
169
|
+
className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
170
|
+
value={scheduleCron}
|
|
171
|
+
placeholder="0 9 * * *"
|
|
172
|
+
spellCheck={false}
|
|
173
|
+
aria-label={t('detail.schedule.cron')}
|
|
174
|
+
onChange={event => { setScheduleCron(event.target.value); setScheduleError(undefined) }}
|
|
175
|
+
/>
|
|
176
|
+
<select
|
|
177
|
+
className={css.schedulePreset}
|
|
178
|
+
value=""
|
|
179
|
+
aria-label={t('detail.schedule.presets')}
|
|
180
|
+
onChange={event => {
|
|
181
|
+
if (event.target.value === '') return
|
|
182
|
+
setScheduleCron(event.target.value)
|
|
183
|
+
setScheduleError(undefined)
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<option value="">{t('detail.schedule.presets')}…</option>
|
|
187
|
+
{SCHEDULE_PRESETS.map(preset => (
|
|
188
|
+
<option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
|
|
189
|
+
))}
|
|
190
|
+
</select>
|
|
191
|
+
</div>
|
|
192
|
+
{scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
|
|
193
|
+
{scheduleError === undefined && scheduleNextRun !== undefined && (
|
|
194
|
+
<p className={css.scheduleMeta}>
|
|
195
|
+
{t('detail.schedule.nextRun')} {new Date(scheduleNextRun).toLocaleString()}
|
|
196
|
+
</p>
|
|
197
|
+
)}
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</section>
|
|
201
|
+
|
|
134
202
|
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
135
203
|
|
|
136
204
|
<footer className={css.modalFooter}>
|
|
@@ -49,7 +49,12 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
49
49
|
const [filter, setFilter] = useState('')
|
|
50
50
|
const [showNew, setShowNew] = useState(false)
|
|
51
51
|
const selected = selectedTaskOf(snapshot)
|
|
52
|
-
const
|
|
52
|
+
const archiveView = snapshot.archiveView
|
|
53
|
+
// Archived tasks leave the columns; the archive view shows them instead.
|
|
54
|
+
const visible = snapshot.tasks.filter(task =>
|
|
55
|
+
(archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined)
|
|
56
|
+
&& matchesFilter(task, filter),
|
|
57
|
+
)
|
|
53
58
|
const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
|
|
54
59
|
|
|
55
60
|
return (
|
|
@@ -64,6 +69,15 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
64
69
|
onChange={event => { setFilter(event.target.value) }}
|
|
65
70
|
aria-label={t('board.search')}
|
|
66
71
|
/>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
className={archiveView ? css.primaryButton : css.ghostButton}
|
|
75
|
+
onClick={() => { controller.toggleArchiveView() }}
|
|
76
|
+
>
|
|
77
|
+
{archiveView
|
|
78
|
+
? t('board.backToBoard')
|
|
79
|
+
: t('board.archiveView', { count: String(snapshot.tasks.filter(task => task.archivedAt !== undefined).length) })}
|
|
80
|
+
</button>
|
|
67
81
|
<button
|
|
68
82
|
type="button"
|
|
69
83
|
className={css.primaryButton}
|
|
@@ -81,24 +95,39 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
81
95
|
</header>
|
|
82
96
|
|
|
83
97
|
<div className={css.columns}>
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
{archiveView ? (
|
|
99
|
+
<section className={css.column} data-status="archived">
|
|
100
|
+
<header className={css.columnHeader}>
|
|
101
|
+
<h3 className={css.columnTitle}>{t('board.archive')}</h3>
|
|
102
|
+
<span className={css.columnCount}>{visible.length}</span>
|
|
103
|
+
</header>
|
|
104
|
+
<div className={css.cards}>
|
|
105
|
+
{visible.map(task => (
|
|
106
|
+
<MemoTaskCard key={task.id} task={task} onOpen={openTask} />
|
|
107
|
+
))}
|
|
108
|
+
{visible.length === 0 && <div className={css.columnEmpty}>{t('archive.empty')}</div>}
|
|
109
|
+
</div>
|
|
110
|
+
</section>
|
|
111
|
+
) : (
|
|
112
|
+
COLUMNS.map(column => {
|
|
113
|
+
const tasks = visible.filter(task => task.status === column.status)
|
|
114
|
+
return (
|
|
115
|
+
<section key={column.status} className={css.column} data-status={column.status}>
|
|
116
|
+
<header className={css.columnHeader}>
|
|
117
|
+
<span className={css.statusDot} data-status={column.status} aria-hidden="true" />
|
|
118
|
+
<h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
|
|
119
|
+
<span className={css.columnCount}>{tasks.length}</span>
|
|
120
|
+
</header>
|
|
121
|
+
<div className={css.cards}>
|
|
122
|
+
{tasks.map(task => (
|
|
123
|
+
<MemoTaskCard key={task.id} task={task} onOpen={openTask} />
|
|
124
|
+
))}
|
|
125
|
+
{tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
)}
|
|
102
131
|
</div>
|
|
103
132
|
|
|
104
133
|
{selected !== undefined && (
|
|
@@ -9,6 +9,7 @@ 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, type TaskStatus } from '../../core/tasks.ts'
|
|
11
11
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
12
|
+
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
12
13
|
import css from '../board.module.css'
|
|
13
14
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
14
15
|
import { formatTime } from './TaskCard.tsx'
|
|
@@ -58,14 +59,6 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
58
59
|
)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/** Common scheduled-run presets (cron → locale label). */
|
|
62
|
-
const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
|
|
63
|
-
{ cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
|
|
64
|
-
{ cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
|
|
65
|
-
{ cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
|
|
66
|
-
{ cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
|
|
67
|
-
]
|
|
68
|
-
|
|
69
62
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
70
63
|
function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
71
64
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
@@ -324,6 +317,31 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
324
317
|
>
|
|
325
318
|
{current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
|
|
326
319
|
</button>
|
|
320
|
+
{current.archivedAt !== undefined ? (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
className={css.primaryButton}
|
|
324
|
+
onClick={() => {
|
|
325
|
+
controller.restoreTask(current.id)
|
|
326
|
+
controller.closeTask()
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
{t('detail.restore')}
|
|
330
|
+
</button>
|
|
331
|
+
) : (
|
|
332
|
+
(current.status === 'done' || current.status === 'failed') && (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
className={css.ghostButton}
|
|
336
|
+
onClick={() => {
|
|
337
|
+
controller.archiveTask(current.id)
|
|
338
|
+
controller.closeTask()
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{t('detail.archive')}
|
|
342
|
+
</button>
|
|
343
|
+
)
|
|
344
|
+
)}
|
|
327
345
|
<button
|
|
328
346
|
type="button"
|
|
329
347
|
className={css.dangerButton}
|
|
@@ -333,6 +351,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
333
351
|
</button>
|
|
334
352
|
<span className={css.detailMeta}>
|
|
335
353
|
{t('board.created')} {formatTime(current.createdAt)}
|
|
354
|
+
{current.archivedAt !== undefined && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt) })}`}
|
|
336
355
|
</span>
|
|
337
356
|
</footer>
|
|
338
357
|
</div>
|
package/src/client/index.ts
CHANGED
|
@@ -178,6 +178,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
178
178
|
// not-yet-ready runtime; tab visibility recovery ticks immediately.
|
|
179
179
|
const scheduler = new SchedulerService({
|
|
180
180
|
tasks: () => controller.getSnapshot().tasks,
|
|
181
|
+
// Re-read the persisted ledger before every fire decision: a task
|
|
182
|
+
// deleted in another tab must never fire from a stale in-memory copy.
|
|
183
|
+
refresh: () => controller.reloadFromStore(),
|
|
181
184
|
now: () => Date.now(),
|
|
182
185
|
runTask: id => controller.runTask(id),
|
|
183
186
|
applySchedule: (id, nextRunAt, lastTriggeredAt) =>
|