@linxin666/dsh-client-ui-task-board 0.3.20 → 0.3.21
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 +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +186 -52
- package/lib/client.js.map +1 -1
- package/lib/index.js +17 -7
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/panel-mount-core.d.ts +1 -1
- package/lib/types/client/sidebar-entry-core.d.ts +0 -15
- package/lib/types/core/controller.d.ts +2 -3
- package/lib/types/core/tasks.d.ts +6 -1
- package/lib/types/core/use-cases/task-archive.d.ts +6 -6
- package/package.json +1 -1
- package/src/client/board/TaskDetail.tsx +4 -2
- package/src/client/body-mutations.ts +133 -0
- package/src/client/index.ts +52 -20
- package/src/client/panel-mount-core.ts +21 -13
- package/src/client/sidebar-entry-core.ts +10 -7
- package/src/core/controller.ts +2 -3
- package/src/core/schedule.ts +4 -1
- package/src/core/tasks.ts +7 -2
- package/src/core/use-cases/task-archive.ts +6 -6
package/lib/index.js
CHANGED
|
@@ -154,7 +154,7 @@ function parseField(field, min, max, out) {
|
|
|
154
154
|
high = Number(b);
|
|
155
155
|
} else if (isDigits(range)) {
|
|
156
156
|
low = Number(range);
|
|
157
|
-
high =
|
|
157
|
+
high = stepRaw === void 0 ? low : max;
|
|
158
158
|
} else return false;
|
|
159
159
|
if (low < min || high > max || low > high) return false;
|
|
160
160
|
const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
|
|
@@ -244,8 +244,18 @@ function retainRecentExecutions(executions) {
|
|
|
244
244
|
const keepSettled = Math.max(20 - open.length, 0);
|
|
245
245
|
return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open];
|
|
246
246
|
}
|
|
247
|
-
/**
|
|
248
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Statuses a task may be archived from: every status but `running`, whose
|
|
249
|
+
* execution the runner still owns until it settles. A settled-only gate made
|
|
250
|
+
* the duplicate-and-archive flow a silent no-op for scheduled tasks, which
|
|
251
|
+
* return to `todo` after every successful run (issue #1447).
|
|
252
|
+
*/
|
|
253
|
+
const ARCHIVABLE_STATUSES = [
|
|
254
|
+
"backlog",
|
|
255
|
+
"todo",
|
|
256
|
+
"done",
|
|
257
|
+
"failed"
|
|
258
|
+
];
|
|
249
259
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
250
260
|
const TASK_PERMISSIONS = [
|
|
251
261
|
"read-only",
|
|
@@ -731,10 +741,10 @@ function parseLedger(raw) {
|
|
|
731
741
|
//#endregion
|
|
732
742
|
//#region src/core/use-cases/task-archive.ts
|
|
733
743
|
/**
|
|
734
|
-
* Archive one task: only
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
744
|
+
* Archive one task: only a `running` task stays on the board (its runner
|
|
745
|
+
* still owns its lifecycle until the execution settles); every other status
|
|
746
|
+
* can be archived. Archiving disarms a schedule; already-archived tasks are
|
|
747
|
+
* a no-op.
|
|
738
748
|
*/
|
|
739
749
|
function applyArchiveTask(tasks, id, now) {
|
|
740
750
|
let applied = false;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `document.body` MutationObserver per page, shared by every family
|
|
3
|
+
* plugin bundle.
|
|
4
|
+
*
|
|
5
|
+
* Family plugins that inject at the DOM level must notice when the shell
|
|
6
|
+
* re-renders around their injected node: the sidebar entry rows
|
|
7
|
+
* (sidebar-entry-core), the center-column panel containers
|
|
8
|
+
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
9
|
+
* used to install its OWN `MutationObserver` on `document.body` with
|
|
10
|
+
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
11
|
+
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
12
|
+
* the app produced — chat token streaming alone produces many per second, and
|
|
13
|
+
* the count grows with the number of installed plugins.
|
|
14
|
+
*
|
|
15
|
+
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
16
|
+
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
17
|
+
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
18
|
+
* reach the same hub at runtime instead of one hub per module instance.
|
|
19
|
+
* Record subscribers receive the mutations accumulated since the last flush.
|
|
20
|
+
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
21
|
+
* when all subscribers use that path, the hub retains no mutation records
|
|
22
|
+
* (or detached subtrees) while a background page's animation frames pause.
|
|
23
|
+
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
24
|
+
* observer, cancels the pending frame and releases its records.
|
|
25
|
+
*
|
|
26
|
+
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
27
|
+
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
28
|
+
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
29
|
+
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
30
|
+
* others.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
34
|
+
* The marked wrapper also works with an older hub, which delivers records
|
|
35
|
+
* that it simply ignores until a page reload picks up the updated hub.
|
|
36
|
+
*/
|
|
37
|
+
export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
|
|
38
|
+
/**
|
|
39
|
+
* Subscribe to body-level childList mutations.
|
|
40
|
+
* @param subscriber - called at most once per animation frame with the records
|
|
41
|
+
* collected since the previous flush; must be safe to run repeatedly.
|
|
42
|
+
* @returns the disposer removing this subscriber (and the observer when it was
|
|
43
|
+
* the last one).
|
|
44
|
+
*/
|
|
45
|
+
export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { type Root } from 'react-dom/client';
|
|
22
22
|
/** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
|
|
23
23
|
export interface CenterPanelMountOptions {
|
|
24
|
-
/** Render the panel
|
|
24
|
+
/** Render the panel tree (first open, remount while open, locale refresh). */
|
|
25
25
|
render: (root: Root) => void;
|
|
26
26
|
/** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
|
|
27
27
|
viewDatasetKey: string;
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared sidebar entry injection core.
|
|
3
|
-
*
|
|
4
|
-
* dsh's sidebar shell exposes no slot an external plugin can register into,
|
|
5
|
-
* so the entry row is injected between the shell's New Session button and the
|
|
6
|
-
* workspace browser. The injection self-heals: a MutationObserver watches the
|
|
7
|
-
* sidebar root and re-inserts the row whenever a React re-render displaces it
|
|
8
|
-
* (re-insertion happens in the same frame, before paint, so no flicker).
|
|
9
|
-
*
|
|
10
|
-
* The row is plain DOM (no React tree) so it can never disturb the shell's
|
|
11
|
-
* reconciliation; the view it toggles is a separate root owned by the caller.
|
|
12
|
-
*
|
|
13
|
-
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
14
|
-
* edit the shared source and re-run the sync instead of editing a copy.
|
|
15
|
-
*/
|
|
16
1
|
/** Per-package configuration for one sidebar entry row. */
|
|
17
2
|
export interface SidebarEntryOptions {
|
|
18
3
|
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
@@ -154,9 +154,8 @@ export declare class BoardController {
|
|
|
154
154
|
moveTask(id: string, status: TaskStatus): void;
|
|
155
155
|
deleteTask(id: string): void;
|
|
156
156
|
/**
|
|
157
|
-
* Archive a
|
|
158
|
-
*
|
|
159
|
-
* lifecycle.
|
|
157
|
+
* Archive a task from any status but `running`, whose lifecycle the runner
|
|
158
|
+
* keeps exclusive ownership of until it settles.
|
|
160
159
|
* @returns true when applied.
|
|
161
160
|
*/
|
|
162
161
|
archiveTask(id: string): boolean;
|
|
@@ -158,7 +158,12 @@ export interface TaskRecord {
|
|
|
158
158
|
*/
|
|
159
159
|
archivedAt?: number;
|
|
160
160
|
}
|
|
161
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Statuses a task may be archived from: every status but `running`, whose
|
|
163
|
+
* execution the runner still owns until it settles. A settled-only gate made
|
|
164
|
+
* the duplicate-and-archive flow a silent no-op for scheduled tasks, which
|
|
165
|
+
* return to `todo` after every successful run (issue #1447).
|
|
166
|
+
*/
|
|
162
167
|
export declare const ARCHIVABLE_STATUSES: readonly TaskStatus[];
|
|
163
168
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
164
169
|
export declare const TASK_PERMISSIONS: readonly ["read-only", "workspace-write", "danger-full-access"];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Archive/restore task use case: move a
|
|
3
|
-
*
|
|
2
|
+
* Archive/restore task use case: move a task off the main board and back,
|
|
3
|
+
* whatever its status but `running`. The task keeps its status, execution history, and
|
|
4
4
|
* transcript references, while archiving disarms any schedule so it cannot
|
|
5
5
|
* create more execution records until the user restores and re-enables it.
|
|
6
6
|
*/
|
|
@@ -13,10 +13,10 @@ export interface ArchiveTaskResult {
|
|
|
13
13
|
archived: boolean;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Archive one task: only
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Archive one task: only a `running` task stays on the board (its runner
|
|
17
|
+
* still owns its lifecycle until the execution settles); every other status
|
|
18
|
+
* can be archived. Archiving disarms a schedule; already-archived tasks are
|
|
19
|
+
* a no-op.
|
|
20
20
|
*/
|
|
21
21
|
export declare function applyArchiveTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult;
|
|
22
22
|
/** Restore one task back onto the main board (clears the archive marker). */
|
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.21",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -347,8 +347,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
347
347
|
</p>
|
|
348
348
|
<p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
|
|
349
349
|
<ul className={css.executionList}>
|
|
350
|
-
{current.handover.references.map(reference => (
|
|
351
|
-
|
|
350
|
+
{current.handover.references.map((reference, index) => (
|
|
351
|
+
// References are free text from the freeze block, so the same
|
|
352
|
+
// string can appear twice; the index keeps the key unique (#1492).
|
|
353
|
+
<li key={`${reference}-${index}`} className={css.executionRow}><code>{reference}</code></li>
|
|
352
354
|
))}
|
|
353
355
|
</ul>
|
|
354
356
|
<p className={css.detailMeta}>{t('detail.handover.bundledAt', { time: formatHostTimestamp(current.handover.bundledAt, timeZone) })}</p>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* One `document.body` MutationObserver per page, shared by every family
|
|
4
|
+
* plugin bundle.
|
|
5
|
+
*
|
|
6
|
+
* Family plugins that inject at the DOM level must notice when the shell
|
|
7
|
+
* re-renders around their injected node: the sidebar entry rows
|
|
8
|
+
* (sidebar-entry-core), the center-column panel containers
|
|
9
|
+
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
10
|
+
* used to install its OWN `MutationObserver` on `document.body` with
|
|
11
|
+
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
12
|
+
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
13
|
+
* the app produced — chat token streaming alone produces many per second, and
|
|
14
|
+
* the count grows with the number of installed plugins.
|
|
15
|
+
*
|
|
16
|
+
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
17
|
+
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
18
|
+
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
19
|
+
* reach the same hub at runtime instead of one hub per module instance.
|
|
20
|
+
* Record subscribers receive the mutations accumulated since the last flush.
|
|
21
|
+
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
22
|
+
* when all subscribers use that path, the hub retains no mutation records
|
|
23
|
+
* (or detached subtrees) while a background page's animation frames pause.
|
|
24
|
+
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
25
|
+
* observer, cancels the pending frame and releases its records.
|
|
26
|
+
*
|
|
27
|
+
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
28
|
+
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
29
|
+
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
30
|
+
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
31
|
+
* others.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The page-wide hub: one observer plus its subscribers and pending records. */
|
|
35
|
+
interface BodyMutationHub {
|
|
36
|
+
observer: MutationObserver
|
|
37
|
+
subscribers: Set<(records: MutationRecord[]) => void>
|
|
38
|
+
pending: MutationRecord[]
|
|
39
|
+
scheduled: boolean
|
|
40
|
+
frame?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
|
|
44
|
+
const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
|
|
45
|
+
const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
|
|
46
|
+
type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
|
|
47
|
+
|
|
48
|
+
function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
|
|
49
|
+
for (const listener of subscribers) {
|
|
50
|
+
if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
57
|
+
* The marked wrapper also works with an older hub, which delivers records
|
|
58
|
+
* that it simply ignores until a page reload picks up the updated hub.
|
|
59
|
+
*/
|
|
60
|
+
export function subscribeBodyInvalidations(subscriber: () => void): () => void {
|
|
61
|
+
const listener: Subscriber = () => { subscriber() }
|
|
62
|
+
listener[INVALIDATION_ONLY] = true
|
|
63
|
+
return subscribeBodyMutations(listener)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Subscribe to body-level childList mutations.
|
|
68
|
+
* @param subscriber - called at most once per animation frame with the records
|
|
69
|
+
* collected since the previous flush; must be safe to run repeatedly.
|
|
70
|
+
* @returns the disposer removing this subscriber (and the observer when it was
|
|
71
|
+
* the last one).
|
|
72
|
+
*/
|
|
73
|
+
export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
|
|
74
|
+
if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
|
|
75
|
+
if (typeof MutationObserver !== 'function') return () => {}
|
|
76
|
+
const registry = globalThis as unknown as Record<symbol, unknown>
|
|
77
|
+
let hub = registry[HUB_KEY] as BodyMutationHub | undefined
|
|
78
|
+
if (hub === undefined) {
|
|
79
|
+
const subscribers = new Set<(records: MutationRecord[]) => void>()
|
|
80
|
+
const created: BodyMutationHub = {
|
|
81
|
+
observer: undefined as unknown as MutationObserver,
|
|
82
|
+
subscribers,
|
|
83
|
+
pending: [],
|
|
84
|
+
scheduled: false,
|
|
85
|
+
}
|
|
86
|
+
const flush = (): void => {
|
|
87
|
+
created.frame = undefined
|
|
88
|
+
created.scheduled = false
|
|
89
|
+
const batch = created.pending
|
|
90
|
+
created.pending = []
|
|
91
|
+
for (const listener of [...subscribers]) {
|
|
92
|
+
if (!subscribers.has(listener)) continue
|
|
93
|
+
try {
|
|
94
|
+
listener(batch)
|
|
95
|
+
} catch {
|
|
96
|
+
// One subscriber must never break the others; the next flush retries it.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const schedule = (): void => {
|
|
101
|
+
if (created.scheduled) return
|
|
102
|
+
created.scheduled = true
|
|
103
|
+
if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
|
|
104
|
+
else flush()
|
|
105
|
+
}
|
|
106
|
+
created.observer = new MutationObserver((records) => {
|
|
107
|
+
if (needsRecords(subscribers)) {
|
|
108
|
+
for (const record of records) created.pending.push(record)
|
|
109
|
+
}
|
|
110
|
+
schedule()
|
|
111
|
+
})
|
|
112
|
+
created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
|
|
113
|
+
registry[HUB_KEY] = created
|
|
114
|
+
hub = created
|
|
115
|
+
}
|
|
116
|
+
const active = hub
|
|
117
|
+
active.subscribers.add(subscriber)
|
|
118
|
+
let subscribed = true
|
|
119
|
+
return () => {
|
|
120
|
+
if (!subscribed) return
|
|
121
|
+
subscribed = false
|
|
122
|
+
active.subscribers.delete(subscriber)
|
|
123
|
+
if (!needsRecords(active.subscribers)) active.pending = []
|
|
124
|
+
if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
|
|
125
|
+
active.observer.disconnect()
|
|
126
|
+
if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
|
|
127
|
+
active.frame = undefined
|
|
128
|
+
active.pending = []
|
|
129
|
+
active.scheduled = false
|
|
130
|
+
delete registry[HUB_KEY]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -80,7 +80,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
80
80
|
* on hosts below that cohort, which serve the same roster through the
|
|
81
81
|
* connection RPC face.
|
|
82
82
|
*/
|
|
83
|
-
export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote']
|
|
83
|
+
export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote', 'remote.session']
|
|
84
84
|
|
|
85
85
|
/** One agent-preset row the mode picker consumes (either face's wire shape). */
|
|
86
86
|
interface PresetRosterRow {
|
|
@@ -251,31 +251,63 @@ export function apply(ctx: ClientContext): void {
|
|
|
251
251
|
}
|
|
252
252
|
const pushModelOptions = async (): Promise<void> => {
|
|
253
253
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
254
|
+
let models: Array<{ id: string; name?: string; provider?: string }> = []
|
|
255
|
+
let sessionRemote: ClientRemote['session'] | undefined
|
|
256
|
+
try {
|
|
257
|
+
sessionRemote = (remote as Partial<ClientRemote>).session
|
|
258
|
+
} catch {
|
|
259
|
+
sessionRemote = undefined
|
|
260
|
+
}
|
|
261
|
+
if (typeof sessionRemote?.modelCatalog === 'function') {
|
|
262
|
+
const res = await sessionRemote.modelCatalog()
|
|
263
|
+
if (res.ok && Array.isArray(res.value?.groups)) {
|
|
264
|
+
for (const g of res.value.groups) {
|
|
265
|
+
const provider = (g as { id?: string; provider?: string }).id ?? (g as { id?: string; provider?: string }).provider
|
|
266
|
+
for (const m of g.models ?? []) {
|
|
267
|
+
const qualifiedId = provider ? `${provider}/${m.id}` : m.id
|
|
268
|
+
models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
|
|
269
|
+
}
|
|
262
270
|
}
|
|
263
271
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
+
}
|
|
273
|
+
if (models.length === 0) {
|
|
274
|
+
const conn = ctx.get('connection') as {
|
|
275
|
+
api?: {
|
|
276
|
+
llm?: { discoverModels?: () => Promise<unknown> }
|
|
277
|
+
sessions?: { modelCatalog?: () => Promise<unknown> }
|
|
278
|
+
session?: { modelCatalog?: () => Promise<unknown> }
|
|
279
|
+
}
|
|
280
|
+
} | undefined
|
|
281
|
+
if (conn?.api) {
|
|
282
|
+
if (typeof conn.api.llm?.discoverModels === 'function') {
|
|
283
|
+
const res = await conn.api.llm.discoverModels() as { result?: { value?: { models?: Array<{ id: string; name?: string }> } } }
|
|
284
|
+
const list = res?.result?.value?.models
|
|
285
|
+
if (Array.isArray(list)) {
|
|
286
|
+
models = list.map(m => ({ id: m.id, name: m.name }))
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const catalogFn = typeof conn.api.session?.modelCatalog === 'function'
|
|
290
|
+
? conn.api.session.modelCatalog
|
|
291
|
+
: typeof conn.api.sessions?.modelCatalog === 'function'
|
|
292
|
+
? conn.api.sessions.modelCatalog
|
|
293
|
+
: undefined
|
|
294
|
+
if (models.length === 0 && catalogFn !== undefined) {
|
|
295
|
+
const res = await catalogFn() as { result?: { value?: { groups?: Array<{ id?: string; provider?: string; models?: Array<{ id: string; name?: string }> }> } } }
|
|
296
|
+
const groups = res?.result?.value?.groups
|
|
297
|
+
if (Array.isArray(groups)) {
|
|
298
|
+
for (const g of groups) {
|
|
299
|
+
const provider = g.id ?? g.provider
|
|
300
|
+
for (const m of g.models ?? []) {
|
|
301
|
+
const qualifiedId = provider ? `${provider}/${m.id}` : m.id
|
|
302
|
+
models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
|
|
303
|
+
}
|
|
272
304
|
}
|
|
273
305
|
}
|
|
274
306
|
}
|
|
275
307
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
308
|
+
}
|
|
309
|
+
if (models.length > 0) {
|
|
310
|
+
controller.setExecutionOptions({ models })
|
|
279
311
|
}
|
|
280
312
|
} catch (error) {
|
|
281
313
|
console.error('[dsh-task-board] model options read failed', error)
|
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
* (shared/client/sidebar-entry-core.ts, synced copy).
|
|
21
21
|
*/
|
|
22
22
|
import { createRoot, type Root } from 'react-dom/client'
|
|
23
|
+
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
23
24
|
|
|
24
25
|
/** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
|
|
25
26
|
export interface CenterPanelMountOptions {
|
|
26
|
-
/** Render the panel
|
|
27
|
+
/** Render the panel tree (first open, remount while open, locale refresh). */
|
|
27
28
|
render: (root: Root) => void
|
|
28
29
|
/** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
|
|
29
30
|
viewDatasetKey: string
|
|
@@ -79,31 +80,38 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
|
|
|
79
80
|
} catch { /* locale service absent: the panel follows its next natural re-render */ }
|
|
80
81
|
|
|
81
82
|
const ensure = (): void => {
|
|
82
|
-
if (container !== undefined) {
|
|
83
|
-
if (container.isConnected) return
|
|
83
|
+
if (container !== undefined && !container.isConnected) {
|
|
84
84
|
// The conversation pane was replaced; drop the stale tree and remount.
|
|
85
85
|
root?.unmount()
|
|
86
86
|
root = undefined
|
|
87
87
|
container.remove()
|
|
88
88
|
container = undefined
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
if (container === undefined) {
|
|
91
|
+
const column = conversationColumn()
|
|
92
|
+
if (column === undefined) return
|
|
93
|
+
container = document.createElement('div')
|
|
94
|
+
container.dataset[options.viewDatasetKey] = ''
|
|
95
|
+
container.dataset.dshPlugin = options.pluginName
|
|
96
|
+
container.className = options.viewClassName
|
|
97
|
+
column.appendChild(container)
|
|
98
|
+
}
|
|
99
|
+
// Keep a visited tree mounted so drafts and terminal sessions survive
|
|
100
|
+
// close/reopen; an unused panel needs neither a React root nor effects.
|
|
101
|
+
if (root !== undefined || !options.isOpen()) return
|
|
97
102
|
root = createRoot(container)
|
|
98
103
|
options.render(root)
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
// The frame mounts after boot settlement; watch for the column's arrival.
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
// The observation is the page-wide hub (shared/client/body-mutations.ts):
|
|
108
|
+
// every family panel used to hold its own document.body subtree observer, so
|
|
109
|
+
// the per-mutation cost grew with the number of installed plugins.
|
|
110
|
+
const unsubscribeBody = subscribeBodyInvalidations(() => { ensure() })
|
|
104
111
|
|
|
105
112
|
const applyActive = (): void => {
|
|
106
113
|
if (options.isOpen()) {
|
|
114
|
+
ensure()
|
|
107
115
|
// Single-occupant center column: opening this panel must evict the
|
|
108
116
|
// sibling panel, both its html attribute and its controller state,
|
|
109
117
|
// otherwise the two panels' visibility rules fight and the second
|
|
@@ -135,7 +143,7 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
|
|
|
135
143
|
return () => {
|
|
136
144
|
document.removeEventListener('click', onClickSidebarRow, true)
|
|
137
145
|
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
138
|
-
|
|
146
|
+
unsubscribeBody()
|
|
139
147
|
unsubscribe()
|
|
140
148
|
unsubscribeLocale?.()
|
|
141
149
|
document.documentElement.removeAttribute(options.activeAttribute)
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
15
15
|
* edit the shared source and re-run the sync instead of editing a copy.
|
|
16
16
|
*/
|
|
17
|
+
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
17
18
|
|
|
18
19
|
/** Per-package configuration for one sidebar entry row. */
|
|
19
20
|
export interface SidebarEntryOptions {
|
|
@@ -188,13 +189,15 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
188
189
|
|
|
189
190
|
// Body-level watcher retained as the "whole rebuild" fallback: when the shell
|
|
190
191
|
// tears down the whole sidebar pane, the root observer is gone with it and
|
|
191
|
-
// only this body observation can notice the new pane mounting. It
|
|
192
|
-
//
|
|
192
|
+
// only this body observation can notice the new pane mounting. It stays
|
|
193
|
+
// subscribed after placement; the placed-and-still-mounted case
|
|
193
194
|
// short-circuits through the cheap document.body.contains(entry) check, so
|
|
194
|
-
// unrelated app mutations (e.g. chat streaming) cost one contains check
|
|
195
|
-
// instead of churning the full re-query.
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
// unrelated app mutations (e.g. chat streaming) cost one contains check per
|
|
196
|
+
// frame instead of churning the full re-query. The body observation itself is
|
|
197
|
+
// the page-wide hub (shared/client/body-mutations.ts): every family plugin
|
|
198
|
+
// used to hold its own body subtree observer, so the per-mutation cost grew
|
|
199
|
+
// with the number of installed plugins; the hub keeps exactly one.
|
|
200
|
+
const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
|
|
198
201
|
|
|
199
202
|
// Self-heal: if a React re-render displaces the row, re-insert it in the
|
|
200
203
|
// same frame (microtask before paint -> no visible flicker).
|
|
@@ -225,7 +228,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
225
228
|
tryPlace()
|
|
226
229
|
|
|
227
230
|
return () => {
|
|
228
|
-
|
|
231
|
+
unsubscribeBody()
|
|
229
232
|
rootObserver.disconnect()
|
|
230
233
|
unsubscribeRefresh?.()
|
|
231
234
|
unsubscribeActive?.()
|
package/src/core/controller.ts
CHANGED
|
@@ -327,9 +327,8 @@ export class BoardController {
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
/**
|
|
330
|
-
* Archive a
|
|
331
|
-
*
|
|
332
|
-
* lifecycle.
|
|
330
|
+
* Archive a task from any status but `running`, whose lifecycle the runner
|
|
331
|
+
* keeps exclusive ownership of until it settles.
|
|
333
332
|
* @returns true when applied.
|
|
334
333
|
*/
|
|
335
334
|
archiveTask(id: string): boolean {
|
package/src/core/schedule.ts
CHANGED
|
@@ -179,7 +179,10 @@ function parseField(field: string, min: number, max: number, out: Set<number>):
|
|
|
179
179
|
high = Number(b)
|
|
180
180
|
} else if (isDigits(range)) {
|
|
181
181
|
low = Number(range)
|
|
182
|
-
|
|
182
|
+
// Standard cron: a bare value carrying a step runs from that value to the
|
|
183
|
+
// field maximum (minutes 5/15 are 5,20,35,50), while a bare value alone is
|
|
184
|
+
// exactly itself (#1493).
|
|
185
|
+
high = stepRaw === undefined ? low : max
|
|
183
186
|
} else {
|
|
184
187
|
return false
|
|
185
188
|
}
|
package/src/core/tasks.ts
CHANGED
|
@@ -172,8 +172,13 @@ export interface TaskRecord {
|
|
|
172
172
|
archivedAt?: number
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Statuses a task may be archived from: every status but `running`, whose
|
|
177
|
+
* execution the runner still owns until it settles. A settled-only gate made
|
|
178
|
+
* the duplicate-and-archive flow a silent no-op for scheduled tasks, which
|
|
179
|
+
* return to `todo` after every successful run (issue #1447).
|
|
180
|
+
*/
|
|
181
|
+
export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'done', 'failed']
|
|
177
182
|
|
|
178
183
|
|
|
179
184
|
/** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Archive/restore task use case: move a
|
|
3
|
-
*
|
|
2
|
+
* Archive/restore task use case: move a task off the main board and back,
|
|
3
|
+
* whatever its status but `running`. The task keeps its status, execution history, and
|
|
4
4
|
* transcript references, while archiving disarms any schedule so it cannot
|
|
5
5
|
* create more execution records until the user restores and re-enables it.
|
|
6
6
|
*/
|
|
@@ -16,10 +16,10 @@ export interface ArchiveTaskResult {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Archive one task: only
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Archive one task: only a `running` task stays on the board (its runner
|
|
20
|
+
* still owns its lifecycle until the execution settles); every other status
|
|
21
|
+
* can be archived. Archiving disarms a schedule; already-archived tasks are
|
|
22
|
+
* a no-op.
|
|
23
23
|
*/
|
|
24
24
|
export function applyArchiveTask(
|
|
25
25
|
tasks: readonly TaskRecord[],
|