@linxin666/dsh-client-ui-task-board 0.1.18 → 0.1.20
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +78 -50
- package/lib/client.js.map +1 -1
- package/lib/index.js +43 -1
- package/lib/types/client/PluginSettingsCard.d.ts +1 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +5 -0
- package/lib/types/client/board/status-key.d.ts +4 -0
- package/lib/types/client/settings-form.d.ts +8 -0
- package/lib/types/core/scheduler.d.ts +3 -0
- package/lib/types/core/tasks.d.ts +3 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/mount-once.d.ts +24 -0
- package/package.json +1 -1
- package/src/client/TaskBoardSettingsCard.tsx +8 -0
- package/src/client/board/TaskBoard.tsx +10 -16
- package/src/client/board/TaskDetail.tsx +1 -9
- package/src/client/board/status-key.ts +11 -0
- package/src/client/board.module.css +68 -1
- package/src/client/index.ts +13 -7
- package/src/client/locales.ts +1 -1
- package/src/client/settings-form.ts +15 -1
- package/src/core/controller.ts +4 -0
- package/src/core/scheduler.ts +12 -2
- package/src/core/store.ts +1 -6
- package/src/core/tasks.ts +1 -1
- package/src/core/use-cases/task-update.ts +1 -6
- package/src/index.ts +4 -1
- package/src/mount-once.ts +48 -0
package/lib/index.js
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
2
|
import z from "schemastery";
|
|
3
|
+
//#region src/mount-once.ts
|
|
4
|
+
/**
|
|
5
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
6
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
7
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
8
|
+
* without this guard the second instance would still re-register the same
|
|
9
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
10
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
11
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
12
|
+
* package name in the client module host).
|
|
13
|
+
*
|
|
14
|
+
* The registry rides a global symbol so two module instances of the same
|
|
15
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
16
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
17
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
18
|
+
*/
|
|
19
|
+
const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
|
|
20
|
+
function mountedSet() {
|
|
21
|
+
const registry = globalThis;
|
|
22
|
+
return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
26
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
27
|
+
* any later mount of the same package name is a no-op.
|
|
28
|
+
* @param packageName - npm package identity shared by every install source.
|
|
29
|
+
* @param fn - the original plugin apply.
|
|
30
|
+
* @returns an apply of the same shape.
|
|
31
|
+
*/
|
|
32
|
+
function mountOnce(packageName, fn) {
|
|
33
|
+
return ((...args) => {
|
|
34
|
+
const mounted = mountedSet();
|
|
35
|
+
if (mounted.has(packageName)) return;
|
|
36
|
+
mounted.add(packageName);
|
|
37
|
+
args[0]?.effect?.(() => () => {
|
|
38
|
+
mounted.delete(packageName);
|
|
39
|
+
});
|
|
40
|
+
return fn(...args);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
3
44
|
//#region src/index.ts
|
|
4
45
|
/** Order of the announcement section within the tool-guidance band. */
|
|
5
46
|
const SECTION_ORDER = 200;
|
|
@@ -26,7 +67,8 @@ const DEFAULT_ANNOUNCE = true;
|
|
|
26
67
|
* @param ctx - the plugin context (systemPrompt injected).
|
|
27
68
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
28
69
|
*/
|
|
29
|
-
|
|
70
|
+
const apply = mountOnce("@linxin666/dsh-client-ui-task-board", applyImpl);
|
|
71
|
+
function applyImpl(ctx, config) {
|
|
30
72
|
let current = () => config ?? {};
|
|
31
73
|
let disposeSection;
|
|
32
74
|
const sync = () => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { type ReactNode } from 'react';
|
|
10
10
|
import type { CardShell } from './settings-form.ts';
|
|
11
11
|
/** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
|
|
12
|
-
export declare const CARD_COPY_KEYS: readonly [
|
|
12
|
+
export declare const CARD_COPY_KEYS: readonly ['settings.collapse', 'settings.expand', 'settings.notExposed', 'settings.unsaved', 'settings.readOnly', 'settings.saveFailed', 'settings.discard', 'settings.save', 'settings.saving'];
|
|
13
13
|
/** Copy key the card chrome itself reads. */
|
|
14
14
|
export type CardCopyKey = (typeof CARD_COPY_KEYS)[number];
|
|
15
15
|
/** Key domain for the plugin's own copy (inferred per consumer). */
|
|
@@ -40,6 +40,11 @@ export declare class TaskBoardSettingsCardController {
|
|
|
40
40
|
* @returns the card's snapshot and its form actions.
|
|
41
41
|
*/
|
|
42
42
|
inject(): TaskBoardSettingsCardFace;
|
|
43
|
+
/**
|
|
44
|
+
* Release the card's scope subscription and bound stores; the slot
|
|
45
|
+
* disposer calls this on teardown.
|
|
46
|
+
*/
|
|
47
|
+
dispose(): void;
|
|
43
48
|
}
|
|
44
49
|
/** Props the renderer binds for the task-board card. */
|
|
45
50
|
export type TaskBoardSettingsCardProps = PropsRuntime<'web-ui.plugin.item'> & PropsLocale<'task-board'> & InjectFace<TaskBoardSettingsCardFace>;
|
|
@@ -149,11 +149,19 @@ export declare class CardForm<T> {
|
|
|
149
149
|
private readonly specs;
|
|
150
150
|
private readonly staged;
|
|
151
151
|
private readonly listeners;
|
|
152
|
+
/** The scope subscription installed in the constructor; released by dispose(). */
|
|
153
|
+
private readonly disposeScope;
|
|
154
|
+
private disposed;
|
|
152
155
|
private saving;
|
|
153
156
|
private failed;
|
|
154
157
|
private failedReason;
|
|
155
158
|
/** @param scope - the bound settings scope for this card's namespace. */
|
|
156
159
|
constructor(scope: SettingsScope<T>, specs: FieldSpec[]);
|
|
160
|
+
/**
|
|
161
|
+
* Release the scope subscription and every bound store listener. The card
|
|
162
|
+
* must call this on teardown; later calls are no-ops.
|
|
163
|
+
*/
|
|
164
|
+
dispose(): void;
|
|
157
165
|
/** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
|
|
158
166
|
bind<S>(project: () => S): SnapshotStore<S>;
|
|
159
167
|
/** Read the card-level state: what the Host serves, and what a save would do. */
|
|
@@ -41,6 +41,9 @@ export declare class SchedulerService {
|
|
|
41
41
|
private environmentListener;
|
|
42
42
|
private disposed;
|
|
43
43
|
private started;
|
|
44
|
+
/** Cron expressions that matched no instant in the scan window, per task —
|
|
45
|
+
* remembered so a never-matching rule is not re-scanned on every tick. */
|
|
46
|
+
private readonly unmatchableRepair;
|
|
44
47
|
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
45
48
|
constructor(deps: SchedulerDeps);
|
|
46
49
|
/**
|
|
@@ -84,7 +84,7 @@ export interface TaskRecord {
|
|
|
84
84
|
/** Statuses a settled task may be archived from. */
|
|
85
85
|
export declare const ARCHIVABLE_STATUSES: readonly TaskStatus[];
|
|
86
86
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
87
|
-
export declare const TASK_PERMISSIONS: readonly [
|
|
87
|
+
export declare const TASK_PERMISSIONS: readonly ['read-only', 'workspace-write', 'danger-full-access'];
|
|
88
88
|
/** One permission preset id. */
|
|
89
89
|
export type TaskPermission = typeof TASK_PERMISSIONS[number];
|
|
90
90
|
/** Whether an unknown value is a known permission preset id. */
|
|
@@ -125,6 +125,8 @@ export declare const ALL_STATUSES: readonly TaskStatus[];
|
|
|
125
125
|
export declare function isTaskStatus(value: unknown): value is TaskStatus;
|
|
126
126
|
/** Whether a manual move target is allowed from the given status. */
|
|
127
127
|
export declare function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean;
|
|
128
|
+
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
129
|
+
export declare function normalizeTargetId(value: string | undefined): string | undefined;
|
|
128
130
|
/** Create a task from user input. */
|
|
129
131
|
export declare function createTask(input: NewTaskInput, now: number, id: string): TaskRecord;
|
|
130
132
|
/** Clone a task with an updated status and a fresh updatedAt. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -41,4 +41,6 @@ export declare const Config: z<Config>;
|
|
|
41
41
|
* @param ctx - the plugin context (systemPrompt injected).
|
|
42
42
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
43
43
|
*/
|
|
44
|
-
export declare
|
|
44
|
+
export declare const apply: typeof applyImpl;
|
|
45
|
+
declare function applyImpl(ctx: Context, config?: Config): void;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
3
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
4
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
5
|
+
* without this guard the second instance would still re-register the same
|
|
6
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
8
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
9
|
+
* package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* The registry rides a global symbol so two module instances of the same
|
|
12
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
13
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
19
|
+
* any later mount of the same package name is a no-op.
|
|
20
|
+
* @param packageName - npm package identity shared by every install source.
|
|
21
|
+
* @param fn - the original plugin apply.
|
|
22
|
+
* @returns an apply of the same shape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T;
|
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.20",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -63,6 +63,14 @@ export class TaskBoardSettingsCardController {
|
|
|
63
63
|
inject(): TaskBoardSettingsCardFace {
|
|
64
64
|
return { hooks: { taskBoardSettingsCard: this.store }, ...this.form.actions() }
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Release the card's scope subscription and bound stores; the slot
|
|
69
|
+
* disposer calls this on teardown.
|
|
70
|
+
*/
|
|
71
|
+
dispose(): void {
|
|
72
|
+
this.form.dispose()
|
|
73
|
+
}
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/** Props the renderer binds for the task-board card. */
|
|
@@ -9,18 +9,10 @@ import { COLUMNS, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
|
|
|
9
9
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
10
10
|
import css from '../board.module.css'
|
|
11
11
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
12
|
+
import { STATUS_KEY } from './status-key.ts'
|
|
12
13
|
import { TaskCard } from './TaskCard.tsx'
|
|
13
14
|
import { TaskDetail } from './TaskDetail.tsx'
|
|
14
15
|
|
|
15
|
-
/** Column status → locale key. */
|
|
16
|
-
const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
17
|
-
backlog: 'board.status.backlog',
|
|
18
|
-
todo: 'board.status.todo',
|
|
19
|
-
running: 'board.status.running',
|
|
20
|
-
done: 'board.status.done',
|
|
21
|
-
failed: 'board.status.failed',
|
|
22
|
-
}
|
|
23
|
-
|
|
24
16
|
/** Case-insensitive title/description match. */
|
|
25
17
|
function matchesFilter(task: TaskRecord, filter: string): boolean {
|
|
26
18
|
if (filter.trim() === '') return true
|
|
@@ -60,6 +52,15 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
60
52
|
return (
|
|
61
53
|
<div className={css.board} data-dsh-taskboard-board="">
|
|
62
54
|
<header className={css.boardHeader}>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
className={`${css.ghostButton} ${css.backButton}`}
|
|
58
|
+
aria-label={t('board.close')}
|
|
59
|
+
onClick={() => { controller.closeBoard() }}
|
|
60
|
+
>
|
|
61
|
+
<span aria-hidden="true">‹</span>
|
|
62
|
+
<span>{t('board.close')}</span>
|
|
63
|
+
</button>
|
|
63
64
|
<h2 className={css.boardTitle}>{t('board.title')}</h2>
|
|
64
65
|
<input
|
|
65
66
|
className={css.search}
|
|
@@ -85,13 +86,6 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
85
86
|
>
|
|
86
87
|
+ {t('board.new')}
|
|
87
88
|
</button>
|
|
88
|
-
<button
|
|
89
|
-
type="button"
|
|
90
|
-
className={css.ghostButton}
|
|
91
|
-
onClick={() => { controller.closeBoard() }}
|
|
92
|
-
>
|
|
93
|
-
{t('board.close')}
|
|
94
|
-
</button>
|
|
95
89
|
</header>
|
|
96
90
|
|
|
97
91
|
<div className={css.columns}>
|
|
@@ -13,6 +13,7 @@ import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
|
13
13
|
import css from '../board.module.css'
|
|
14
14
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
15
15
|
import { formatTime } from './TaskCard.tsx'
|
|
16
|
+
import { STATUS_KEY } from './status-key.ts'
|
|
16
17
|
|
|
17
18
|
/** Execution outcome → locale key. */
|
|
18
19
|
const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
|
|
@@ -21,15 +22,6 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
|
|
|
21
22
|
cancelled: 'detail.result.cancelled',
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** Status → locale key (detail badge). */
|
|
25
|
-
const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
26
|
-
backlog: 'board.status.backlog',
|
|
27
|
-
todo: 'board.status.todo',
|
|
28
|
-
running: 'board.status.running',
|
|
29
|
-
done: 'board.status.done',
|
|
30
|
-
failed: 'board.status.failed',
|
|
31
|
-
}
|
|
32
|
-
|
|
33
25
|
/** One execution-history row. */
|
|
34
26
|
function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
|
|
35
27
|
const result = execution.result
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TaskStatus } from '../../core/tasks.ts'
|
|
2
|
+
import type { TaskBoardKey } from '../locales.ts'
|
|
3
|
+
|
|
4
|
+
/** Task status → locale key (board column titles and the detail badge). */
|
|
5
|
+
export const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
6
|
+
backlog: 'board.status.backlog',
|
|
7
|
+
todo: 'board.status.todo',
|
|
8
|
+
running: 'board.status.running',
|
|
9
|
+
done: 'board.status.done',
|
|
10
|
+
failed: 'board.status.failed',
|
|
11
|
+
}
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
inset: 0;
|
|
19
19
|
display: none;
|
|
20
20
|
z-index: 60;
|
|
21
|
+
container-name: task-board-view;
|
|
22
|
+
container-type: inline-size;
|
|
21
23
|
/* Opaque backdrop: the conversation subtree stays mounted under the view.
|
|
22
24
|
Even if the hide rule below were defeated by a stronger host inline
|
|
23
25
|
style, this background keeps sticky code-block banners (host z-index 6)
|
|
@@ -103,6 +105,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
103
105
|
.board {
|
|
104
106
|
display: flex;
|
|
105
107
|
flex-direction: column;
|
|
108
|
+
box-sizing: border-box;
|
|
106
109
|
height: 100%;
|
|
107
110
|
min-width: 0;
|
|
108
111
|
min-height: 0;
|
|
@@ -128,6 +131,12 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
128
131
|
white-space: nowrap;
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
.backButton {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 4px;
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
.search {
|
|
132
141
|
flex: 0 1 260px;
|
|
133
142
|
min-width: 120px;
|
|
@@ -148,10 +157,38 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
148
157
|
|
|
149
158
|
.columns {
|
|
150
159
|
display: grid;
|
|
151
|
-
grid-
|
|
160
|
+
grid-auto-flow: column;
|
|
161
|
+
grid-auto-columns: minmax(220px, 1fr);
|
|
152
162
|
gap: 12px;
|
|
153
163
|
flex: 1;
|
|
154
164
|
min-height: 0;
|
|
165
|
+
overflow-x: auto;
|
|
166
|
+
overflow-y: hidden;
|
|
167
|
+
overscroll-behavior-inline: contain;
|
|
168
|
+
padding-bottom: 6px;
|
|
169
|
+
scrollbar-color: var(--dsw-alias-border-l3) var(--dsw-alias-interactive-bg-hover);
|
|
170
|
+
scrollbar-width: thin;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.columns::-webkit-scrollbar {
|
|
174
|
+
height: 10px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.columns::-webkit-scrollbar-track {
|
|
178
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
179
|
+
border-radius: 999px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.columns::-webkit-scrollbar-thumb {
|
|
183
|
+
background: var(--dsw-alias-border-l3);
|
|
184
|
+
background-clip: content-box;
|
|
185
|
+
border: 2px solid transparent;
|
|
186
|
+
border-radius: 999px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.columns::-webkit-scrollbar-thumb:hover {
|
|
190
|
+
background: var(--dsw-alias-border-l4);
|
|
191
|
+
background-clip: content-box;
|
|
155
192
|
}
|
|
156
193
|
|
|
157
194
|
.column {
|
|
@@ -770,6 +807,36 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
|
|
|
770
807
|
color: var(--dsw-alias-label-tertiary);
|
|
771
808
|
}
|
|
772
809
|
|
|
810
|
+
/* --- constrained board containers ------------------------------------------ */
|
|
811
|
+
|
|
812
|
+
@container task-board-view (max-width: 720px) {
|
|
813
|
+
.board {
|
|
814
|
+
gap: 10px;
|
|
815
|
+
padding: 10px;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
.columns {
|
|
819
|
+
gap: 10px;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
@container task-board-view (max-width: 600px) {
|
|
824
|
+
.boardHeader {
|
|
825
|
+
flex-wrap: wrap;
|
|
826
|
+
gap: 8px;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
.search {
|
|
830
|
+
flex: 1 1 calc(100% - 72px);
|
|
831
|
+
min-width: 0;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
.boardHeader > button {
|
|
835
|
+
flex: 1 1 0;
|
|
836
|
+
min-width: max-content;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
773
840
|
/* -----------------------------------------------------------------------------
|
|
774
841
|
* Polish pass: complete interaction states (hover / active / focus-visible /
|
|
775
842
|
* disabled), unified 120ms motion, and reduced-motion fallback.
|
package/src/client/index.ts
CHANGED
|
@@ -92,13 +92,19 @@ export function apply(ctx: ClientContext): void {
|
|
|
92
92
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
93
93
|
const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
|
|
94
94
|
const settingsCard = new TaskBoardSettingsCardController(settingsScope)
|
|
95
|
-
ctx.slots.inject('web-ui.plugin.item', () =>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
ctx.slots.inject('web-ui.plugin.item', () => {
|
|
96
|
+
const unregister = ctx.slots.register({
|
|
97
|
+
name: 'web-ui.plugin.item',
|
|
98
|
+
id: 'task-board',
|
|
99
|
+
order: 110,
|
|
100
|
+
locale: NS,
|
|
101
|
+
inject: () => settingsCard.inject(),
|
|
102
|
+
}, TaskBoardSettingsCard)
|
|
103
|
+
return () => {
|
|
104
|
+
settingsCard.dispose()
|
|
105
|
+
unregister()
|
|
106
|
+
}
|
|
107
|
+
})
|
|
102
108
|
|
|
103
109
|
// The sidebar entry and board view mount once the settings scope settles;
|
|
104
110
|
// while the scope is still loading, the composition default is unknown, so
|
package/src/client/locales.ts
CHANGED
|
@@ -234,6 +234,9 @@ export class CardForm<T> {
|
|
|
234
234
|
private readonly specs: Map<string, FieldSpec>
|
|
235
235
|
private readonly staged = new Map<string, StagedEdit>()
|
|
236
236
|
private readonly listeners = new Set<() => void>()
|
|
237
|
+
/** The scope subscription installed in the constructor; released by dispose(). */
|
|
238
|
+
private readonly disposeScope: () => void
|
|
239
|
+
private disposed = false
|
|
237
240
|
private saving = false
|
|
238
241
|
private failed = false
|
|
239
242
|
private failedReason: string | undefined
|
|
@@ -244,7 +247,18 @@ export class CardForm<T> {
|
|
|
244
247
|
specs: FieldSpec[],
|
|
245
248
|
) {
|
|
246
249
|
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
247
|
-
scope.subscribe(() => { this.publish() })
|
|
250
|
+
this.disposeScope = scope.subscribe(() => { this.publish() })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Release the scope subscription and every bound store listener. The card
|
|
255
|
+
* must call this on teardown; later calls are no-ops.
|
|
256
|
+
*/
|
|
257
|
+
dispose(): void {
|
|
258
|
+
if (this.disposed) return
|
|
259
|
+
this.disposed = true
|
|
260
|
+
this.disposeScope()
|
|
261
|
+
this.listeners.clear()
|
|
248
262
|
}
|
|
249
263
|
|
|
250
264
|
/** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
|
package/src/core/controller.ts
CHANGED
|
@@ -89,6 +89,10 @@ export function selectedTaskOf(snapshot: ControllerSnapshot): TaskRecord | undef
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function randomUuid(): string {
|
|
92
|
+
const randomUUID = globalThis.crypto?.randomUUID
|
|
93
|
+
if (randomUUID !== undefined) {
|
|
94
|
+
return randomUUID.call(globalThis.crypto!)
|
|
95
|
+
}
|
|
92
96
|
const bytes = globalThis.crypto?.getRandomValues(new Uint8Array(16))
|
|
93
97
|
if (bytes === undefined) {
|
|
94
98
|
// Non-secure fallback (tests, odd environments).
|
package/src/core/scheduler.ts
CHANGED
|
@@ -68,6 +68,9 @@ export class SchedulerService {
|
|
|
68
68
|
private environmentListener: (() => void) | undefined
|
|
69
69
|
private disposed = false
|
|
70
70
|
private started = false
|
|
71
|
+
/** Cron expressions that matched no instant in the scan window, per task —
|
|
72
|
+
* remembered so a never-matching rule is not re-scanned on every tick. */
|
|
73
|
+
private readonly unmatchableRepair = new Map<string, string>()
|
|
71
74
|
|
|
72
75
|
/** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
|
|
73
76
|
constructor(private readonly deps: SchedulerDeps) {}
|
|
@@ -133,9 +136,16 @@ export class SchedulerService {
|
|
|
133
136
|
if (schedule === undefined || !schedule.enabled) continue
|
|
134
137
|
if (schedule.nextRunAt === undefined) {
|
|
135
138
|
// Missing next-run instant (repaired/legacy data): recompute from the
|
|
136
|
-
// cron expression and wait; an unparseable expression is skipped.
|
|
139
|
+
// cron expression and wait; an unparseable expression is skipped. A
|
|
140
|
+
// grammatically valid rule with no match in the window (e.g. Feb 30)
|
|
141
|
+
// would otherwise re-run the full minute-by-minute scan every tick.
|
|
142
|
+
if (this.unmatchableRepair.get(task.id) === schedule.cron) continue
|
|
137
143
|
const repaired = nextRunAtMs(schedule.cron, now)
|
|
138
|
-
if (repaired === undefined)
|
|
144
|
+
if (repaired === undefined) {
|
|
145
|
+
this.unmatchableRepair.set(task.id, schedule.cron)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
this.unmatchableRepair.delete(task.id)
|
|
139
149
|
this.deps.applySchedule(task.id, repaired, undefined)
|
|
140
150
|
continue
|
|
141
151
|
}
|
package/src/core/store.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* localStorage backend.
|
|
15
15
|
*/
|
|
16
16
|
import { isValidCron } from './schedule.ts'
|
|
17
|
-
import { isTaskPermission, isTaskStatus, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
17
|
+
import { isTaskPermission, isTaskStatus, normalizeTargetId, type ScheduleRule, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
18
18
|
|
|
19
19
|
/** Persistence seam for the task ledger. */
|
|
20
20
|
export interface TaskStore {
|
|
@@ -80,11 +80,6 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
80
80
|
return true
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/** Collapse a blank persisted target string to undefined (clears the pin). */
|
|
84
|
-
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
85
|
-
return value !== undefined && value.trim() === '' ? undefined : value
|
|
86
|
-
}
|
|
87
|
-
|
|
88
83
|
/** A task record is structurally valid if it round-trips through the UI. */
|
|
89
84
|
export function isTaskRecord(value: unknown): value is TaskRecord {
|
|
90
85
|
return isTaskRecordShape(value) && isTaskStatus(value.status)
|
package/src/core/tasks.ts
CHANGED
|
@@ -151,7 +151,7 @@ export function canMoveManually(_from: TaskStatus, to: TaskStatus): boolean {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
154
|
-
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
154
|
+
export function normalizeTargetId(value: string | undefined): string | undefined {
|
|
155
155
|
const trimmed = value?.trim()
|
|
156
156
|
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
157
157
|
}
|
|
@@ -8,16 +8,11 @@
|
|
|
8
8
|
* back to the runtime default); an unknown permission string is ignored so
|
|
9
9
|
* stale UI can never persist a value the execution service rejects.
|
|
10
10
|
*/
|
|
11
|
-
import { isTaskPermission, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
11
|
+
import { isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
12
12
|
|
|
13
13
|
/** Editable fields on a task (the update patch surface). */
|
|
14
14
|
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
|
|
15
15
|
|
|
16
|
-
/** Collapse a blank target string to undefined (clears the pin). */
|
|
17
|
-
function normalizeTargetId(value: string | undefined): string | undefined {
|
|
18
|
-
return value !== undefined && value.trim() === '' ? undefined : value
|
|
19
|
-
}
|
|
20
|
-
|
|
21
16
|
/** Keep an unknown permission string from entering the ledger. */
|
|
22
17
|
function normalizePermission(
|
|
23
18
|
current: TaskPermission | undefined,
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
15
15
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
16
16
|
import z from 'schemastery'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
18
|
+
import { mountOnce } from './mount-once.ts'
|
|
18
19
|
|
|
19
20
|
/** Order of the announcement section within the tool-guidance band. */
|
|
20
21
|
const SECTION_ORDER = 200
|
|
@@ -59,7 +60,9 @@ const DEFAULT_ANNOUNCE = true
|
|
|
59
60
|
* @param ctx - the plugin context (systemPrompt injected).
|
|
60
61
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
61
62
|
*/
|
|
62
|
-
export
|
|
63
|
+
export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl)
|
|
64
|
+
|
|
65
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
63
66
|
// The live source the announcement reads: the settings section once the web
|
|
64
67
|
// settings surface is served, the composition entry otherwise
|
|
65
68
|
// (installSettingsSection swaps it when the namespace registers).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|