@linxin666/dsh-client-ui-task-board 0.3.23 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +275 -48
- package/lib/client.js.map +1 -1
- package/lib/index.js +65 -18
- package/lib/types/client/board/TaskForm.d.ts +6 -1
- package/lib/types/client/board/parse-model-pref.d.ts +4 -0
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/plugin-card-seat.d.ts +96 -0
- package/lib/types/client/sidebar-entry-core.d.ts +32 -0
- package/lib/types/core/session-reuse.d.ts +4 -2
- package/lib/types/host-ledger.d.ts +15 -0
- package/package.json +2 -1
- package/src/client/board/NewTaskModal.tsx +33 -7
- package/src/client/board/TaskForm.tsx +13 -0
- package/src/client/board/parse-model-pref.ts +35 -0
- package/src/client/index.ts +11 -17
- package/src/client/locales.ts +2 -0
- package/src/client/plugin-card-seat.ts +185 -0
- package/src/client/sidebar-entry-core.ts +96 -12
- package/src/core/session-reuse.ts +14 -5
- package/src/host-ledger.ts +59 -11
package/lib/index.js
CHANGED
|
@@ -181,8 +181,10 @@ function isDigits(value) {
|
|
|
181
181
|
* Pick the session a new execution of this task may continue in, or undefined
|
|
182
182
|
* to mint a fresh conversation. Reuse requires ALL of:
|
|
183
183
|
* - the task opted in (`reuseSession === true`);
|
|
184
|
-
* - the task's newest execution carries a session id
|
|
185
|
-
*
|
|
184
|
+
* - the task's newest SETTLED execution carries a session id (issue #1587: the
|
|
185
|
+
* launch path calls this after `startExecution` appended this run's own open
|
|
186
|
+
* record, so reading the array tail always found an unsettled row and reuse
|
|
187
|
+
* never happened);
|
|
186
188
|
* - the roster is known and that session is present and idle.
|
|
187
189
|
*
|
|
188
190
|
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
@@ -194,8 +196,15 @@ function isDigits(value) {
|
|
|
194
196
|
*/
|
|
195
197
|
function reusableSessionId(task, idleSessionIds) {
|
|
196
198
|
if (task.reuseSession !== true || idleSessionIds === void 0) return void 0;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
+
let last;
|
|
200
|
+
for (let index = task.executions.length - 1; index >= 0; index -= 1) {
|
|
201
|
+
const candidate = task.executions[index];
|
|
202
|
+
if (candidate.sessionId !== void 0 && candidate.endedAt !== void 0) {
|
|
203
|
+
last = candidate;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (last === void 0 || last.sessionId === void 0) return void 0;
|
|
199
208
|
return idleSessionIds.has(last.sessionId) ? last.sessionId : void 0;
|
|
200
209
|
}
|
|
201
210
|
//#endregion
|
|
@@ -1406,6 +1415,11 @@ function processIsAlive(pid) {
|
|
|
1406
1415
|
}
|
|
1407
1416
|
}
|
|
1408
1417
|
const PROCESS_PROBE_TIMEOUT_MS = 3e3;
|
|
1418
|
+
/**
|
|
1419
|
+
* The CIM fallback pays a WMI cold start, so it gets a wider budget than the
|
|
1420
|
+
* direct `Get-Process` read it backs up.
|
|
1421
|
+
*/
|
|
1422
|
+
const CIM_PROBE_TIMEOUT_MS = 8e3;
|
|
1409
1423
|
let ownStartTime;
|
|
1410
1424
|
let ownStartTimeResolved = false;
|
|
1411
1425
|
/**
|
|
@@ -1430,6 +1444,52 @@ function linuxStartTimeMs(pid) {
|
|
|
1430
1444
|
return;
|
|
1431
1445
|
}
|
|
1432
1446
|
}
|
|
1447
|
+
/** Default probe: one hidden, profile-free PowerShell process per script. */
|
|
1448
|
+
const runPowerShellProbe = (script, timeoutMs) => {
|
|
1449
|
+
const probe = spawnSync("powershell", [
|
|
1450
|
+
"-NoProfile",
|
|
1451
|
+
"-NonInteractive",
|
|
1452
|
+
"-Command",
|
|
1453
|
+
script
|
|
1454
|
+
], {
|
|
1455
|
+
timeout: timeoutMs,
|
|
1456
|
+
windowsHide: true
|
|
1457
|
+
});
|
|
1458
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
1459
|
+
return probe.stdout.toString("utf8").trim();
|
|
1460
|
+
};
|
|
1461
|
+
/** The epoch-millisecond reading a probe printed, or undefined when unusable. */
|
|
1462
|
+
function parseProbeEpochMs(raw) {
|
|
1463
|
+
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
1464
|
+
const started = Number(raw.trim());
|
|
1465
|
+
return Number.isFinite(started) ? started : void 0;
|
|
1466
|
+
}
|
|
1467
|
+
/** Start time through Get-Process: precise, but empty for protected processes. */
|
|
1468
|
+
function getProcessStartScript(pid) {
|
|
1469
|
+
return "[DateTimeOffset]::FromFileTime((Get-Process -Id " + String(pid) + " -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()";
|
|
1470
|
+
}
|
|
1471
|
+
/** Start time through Win32_Process: readable for System/svchost too. */
|
|
1472
|
+
function cimStartScript(pid) {
|
|
1473
|
+
return "$p=Get-CimInstance Win32_Process -Filter \"ProcessId=" + String(pid) + "\" -ErrorAction SilentlyContinue;if($p -ne $null){[DateTimeOffset]::FromFileTime($p.CreationDate.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()}";
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Windows start time (Unix epoch ms) of a live process.
|
|
1477
|
+
*
|
|
1478
|
+
* `Get-Process` is the precise first choice, but an unprivileged caller cannot
|
|
1479
|
+
* read `.StartTime` for a protected process (System, svchost): the property is
|
|
1480
|
+
* empty, so the probe returns nothing. Without a fallback, a crash leftover
|
|
1481
|
+
* lock whose PID was reused by such a process could never be proven stale and
|
|
1482
|
+
* blocked every startup until the lock was deleted by hand (issue #1629).
|
|
1483
|
+
* Win32_Process through CIM reports the same CreationDate for those processes
|
|
1484
|
+
* at the same millisecond precision, so it is the second identity source. The
|
|
1485
|
+
* probe is injectable so the fallback chain is testable off Windows.
|
|
1486
|
+
*/
|
|
1487
|
+
function win32StartTimeMs(pid, probe = runPowerShellProbe) {
|
|
1488
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return void 0;
|
|
1489
|
+
const direct = parseProbeEpochMs(probe(getProcessStartScript(pid), PROCESS_PROBE_TIMEOUT_MS));
|
|
1490
|
+
if (direct !== void 0) return direct;
|
|
1491
|
+
return parseProbeEpochMs(probe(cimStartScript(pid), CIM_PROBE_TIMEOUT_MS));
|
|
1492
|
+
}
|
|
1433
1493
|
/**
|
|
1434
1494
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
1435
1495
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
@@ -1439,20 +1499,7 @@ function linuxStartTimeMs(pid) {
|
|
|
1439
1499
|
*/
|
|
1440
1500
|
function processStartTimeMs(pid) {
|
|
1441
1501
|
if (process.platform === "linux") return linuxStartTimeMs(pid);
|
|
1442
|
-
if (process.platform === "win32")
|
|
1443
|
-
const probe = spawnSync("powershell", [
|
|
1444
|
-
"-NoProfile",
|
|
1445
|
-
"-NonInteractive",
|
|
1446
|
-
"-Command",
|
|
1447
|
-
"[DateTimeOffset]::FromFileTime((Get-Process -Id " + String(pid) + " -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()"
|
|
1448
|
-
], {
|
|
1449
|
-
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
1450
|
-
windowsHide: true
|
|
1451
|
-
});
|
|
1452
|
-
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
1453
|
-
const started = Number(probe.stdout.toString("utf8").trim());
|
|
1454
|
-
return Number.isFinite(started) ? started : void 0;
|
|
1455
|
-
}
|
|
1502
|
+
if (process.platform === "win32") return win32StartTimeMs(pid);
|
|
1456
1503
|
const env = {
|
|
1457
1504
|
...process.env,
|
|
1458
1505
|
LC_ALL: "C"
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ReactNode } from 'react';
|
|
8
8
|
import { type TaskTag } from '../../core/tasks.ts';
|
|
9
9
|
/** Modal overlay: closes on backdrop press, submits through the form. */
|
|
10
|
-
export declare function ModalShell({ ariaLabel, title, error, pending, submitLabel, onSubmit, onClose, children, }: {
|
|
10
|
+
export declare function ModalShell({ ariaLabel, title, error, pending, submitLabel, onSubmit, onClose, secondaryAction, children, }: {
|
|
11
11
|
ariaLabel: string;
|
|
12
12
|
title: string;
|
|
13
13
|
error: string | undefined;
|
|
@@ -15,6 +15,11 @@ export declare function ModalShell({ ariaLabel, title, error, pending, submitLab
|
|
|
15
15
|
submitLabel: string;
|
|
16
16
|
onSubmit: () => void;
|
|
17
17
|
onClose: () => void;
|
|
18
|
+
/** Optional second action beside the primary submit (e.g. create and run). */
|
|
19
|
+
secondaryAction?: {
|
|
20
|
+
label: string;
|
|
21
|
+
onSubmit: () => void;
|
|
22
|
+
};
|
|
18
23
|
children: ReactNode;
|
|
19
24
|
}): import("react").JSX.Element;
|
|
20
25
|
/** Title + description + prompt fields shared by the new and edit task forms. */
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The remembered parse model; '' means the Host default (or no preference). */
|
|
2
|
+
export declare function readParseModelPreference(storage?: Pick<Storage, 'getItem'> | undefined): string;
|
|
3
|
+
/** Remember the picked parse model; '' records the Host-default choice. */
|
|
4
|
+
export declare function writeParseModelPreference(model: string, storage?: Pick<Storage, 'setItem'> | undefined): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Family plugin-card seat.
|
|
3
|
+
*
|
|
4
|
+
* A family plugin contributes its settings card to whichever plugin-card seat
|
|
5
|
+
* the running host actually renders:
|
|
6
|
+
*
|
|
7
|
+
* - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
|
|
8
|
+
* group section (this family's own first-level "Web UI plugins" section);
|
|
9
|
+
* - `settings.plugin.item` — the official keyed seat of the harness's
|
|
10
|
+
* `ui-settings-plugins` tab, keyed by the settings namespace the card edits.
|
|
11
|
+
*
|
|
12
|
+
* SEAT SELECTION IS NOT A DECLARATION PROBE. The official `ui-settings-plugins`
|
|
13
|
+
* row belongs to the harness bundle and its `configurable` tab always declares
|
|
14
|
+
* `settings.plugin.item` before any external plugin's `apply()` runs, so
|
|
15
|
+
* "is the official seat declared?" answers yes even in the deployment whose
|
|
16
|
+
* whole point is the family group. Choosing on that probe sends every family
|
|
17
|
+
* card to the official Plugins tab and leaves the group's own section
|
|
18
|
+
* permanently empty — the family of reports where the section renders its
|
|
19
|
+
* heading and zero cards.
|
|
20
|
+
*
|
|
21
|
+
* The signal that actually distinguishes the two deployments is whether
|
|
22
|
+
* dsh-web-settings is loaded: it is the package that owns the group section and
|
|
23
|
+
* it publishes the `webUiSettings` service during `apply()`, which every
|
|
24
|
+
* family plugin already reads for its settings scope. Group loaded -> the family
|
|
25
|
+
* seat; group absent -> the official seat.
|
|
26
|
+
*
|
|
27
|
+
* The decision is re-evaluated on every `slots/changed` because the group may
|
|
28
|
+
* apply after this plugin (the family aggregate orders it first, a profile that
|
|
29
|
+
* installs the group separately need not): the initial contribution goes to the
|
|
30
|
+
* official seat, then moves to the family seat the moment the group's section
|
|
31
|
+
* registers. The entry is disposed before the replacement is registered, so a
|
|
32
|
+
* card is never in two seats at once.
|
|
33
|
+
*
|
|
34
|
+
* The shared tree has no client-SDK dependency, so this module reads its
|
|
35
|
+
* context through the structural shape below; callers pass the plugin's own
|
|
36
|
+
* `ctx`.
|
|
37
|
+
*/
|
|
38
|
+
/** The family list seat key. */
|
|
39
|
+
export declare const FAMILY_PLUGIN_CARD_SEAT = "web-ui.plugin.item";
|
|
40
|
+
/** The official keyed plugin-card seat key. */
|
|
41
|
+
export declare const OFFICIAL_PLUGIN_CARD_SEAT = "settings.plugin.item";
|
|
42
|
+
/** The service dsh-web-settings publishes while it is loaded. */
|
|
43
|
+
export declare const FAMILY_GROUP_SERVICE = "webUiSettings";
|
|
44
|
+
/** The slot-registry member this helper uses (structurally satisfied by ctx.slots). */
|
|
45
|
+
export interface PluginCardSlots {
|
|
46
|
+
/** Contribution of one card entry. */
|
|
47
|
+
register(options: never, component: never): unknown;
|
|
48
|
+
}
|
|
49
|
+
/** The slice of the client context a card contribution needs. */
|
|
50
|
+
export interface PluginCardContext {
|
|
51
|
+
slots: PluginCardSlots;
|
|
52
|
+
/** Service lookup; absent on a context double that only models the slots. */
|
|
53
|
+
get?(name: string): unknown;
|
|
54
|
+
/** Event subscription seat; absent on an event-less test double. */
|
|
55
|
+
on?(event: string, listener: (...args: never[]) => void): unknown;
|
|
56
|
+
}
|
|
57
|
+
/** Owner share of a plugin card (both seats supply nothing). */
|
|
58
|
+
export interface SettingsPluginItemOwnerProps {
|
|
59
|
+
/** Marker field: card owner props are intentionally empty. */
|
|
60
|
+
children?: never;
|
|
61
|
+
}
|
|
62
|
+
/** One family plugin's card contribution. */
|
|
63
|
+
export interface PluginCardSeat {
|
|
64
|
+
/** Settings namespace the card edits (the official seat's dispatch key). */
|
|
65
|
+
namespace: string;
|
|
66
|
+
/** Family list-seat entry id. */
|
|
67
|
+
id: string;
|
|
68
|
+
/** Family list-seat sort order. */
|
|
69
|
+
order?: number;
|
|
70
|
+
/** Family list-seat display label; the official keyed seat carries none. */
|
|
71
|
+
label?: () => string;
|
|
72
|
+
/** Locale namespace the card renders with. */
|
|
73
|
+
locale: string;
|
|
74
|
+
/** Business-face factory of the registration. */
|
|
75
|
+
inject?: () => object;
|
|
76
|
+
/**
|
|
77
|
+
* The card component. Its props type is the seat's composed shape, which is
|
|
78
|
+
* chosen at runtime, so the helper takes it erased; the registrant keeps its
|
|
79
|
+
* own precise typing at the call site.
|
|
80
|
+
*/
|
|
81
|
+
component: unknown;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether the family group (dsh-web-settings) is loaded in this page. The
|
|
85
|
+
* service is the group package's own contract, so the probe cannot be fooled
|
|
86
|
+
* by a harness release that starts declaring the official seat differently.
|
|
87
|
+
*/
|
|
88
|
+
export declare function familyGroupLoaded(ctx: PluginCardContext): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Contribute one family plugin card to the seat this host renders, following
|
|
91
|
+
* the group if it loads later. The entry is disposed and re-registered on a
|
|
92
|
+
* seat change, never duplicated.
|
|
93
|
+
* @param ctx - client context (its slot registry decides the seat).
|
|
94
|
+
* @param seat - the card contribution.
|
|
95
|
+
*/
|
|
96
|
+
export declare function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat): void;
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One trailing action button seated at the entry row's right edge (e.g. a
|
|
3
|
+
* refresh command or a collapse chevron). Rows with actions switch from the
|
|
4
|
+
* classic single-button structure to a container with a main button plus the
|
|
5
|
+
* action buttons (nested interactive elements are invalid HTML); rows without
|
|
6
|
+
* actions keep the classic structure untouched.
|
|
7
|
+
*/
|
|
8
|
+
export interface SidebarEntryAction {
|
|
9
|
+
/** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
|
|
12
|
+
icon: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional icon while the row is inactive. Setting it makes the action a
|
|
15
|
+
* state mirror (a collapse chevron): the core swaps the icon and stamps
|
|
16
|
+
* aria-expanded on every active-state change. Requires the active bridge.
|
|
17
|
+
*/
|
|
18
|
+
inactiveIcon?: string;
|
|
19
|
+
/** Localized accessible label (aria-label + title); receives the active state. */
|
|
20
|
+
label(active: boolean): string;
|
|
21
|
+
/**
|
|
22
|
+
* Click handler; receives the button so the caller can drive transient
|
|
23
|
+
* affordances (a refresh cooldown) without a re-render.
|
|
24
|
+
*/
|
|
25
|
+
onClick(button: HTMLButtonElement): void;
|
|
26
|
+
}
|
|
1
27
|
/** Per-package configuration for one sidebar entry row. */
|
|
2
28
|
export interface SidebarEntryOptions {
|
|
3
29
|
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
@@ -31,6 +57,12 @@ export interface SidebarEntryOptions {
|
|
|
31
57
|
};
|
|
32
58
|
/** Click action (open/toggle the owning panel). */
|
|
33
59
|
onToggle(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Optional trailing action buttons at the row's right edge. Requires the
|
|
62
|
+
* package CSS to seat entryMain / entryAction; empty keeps the classic
|
|
63
|
+
* single-button row.
|
|
64
|
+
*/
|
|
65
|
+
actions?: readonly SidebarEntryAction[];
|
|
34
66
|
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
35
67
|
position: 'before' | 'after';
|
|
36
68
|
/**
|
|
@@ -8,8 +8,10 @@ import type { TaskRecord } from './tasks.ts';
|
|
|
8
8
|
* Pick the session a new execution of this task may continue in, or undefined
|
|
9
9
|
* to mint a fresh conversation. Reuse requires ALL of:
|
|
10
10
|
* - the task opted in (`reuseSession === true`);
|
|
11
|
-
* - the task's newest execution carries a session id
|
|
12
|
-
*
|
|
11
|
+
* - the task's newest SETTLED execution carries a session id (issue #1587: the
|
|
12
|
+
* launch path calls this after `startExecution` appended this run's own open
|
|
13
|
+
* record, so reading the array tail always found an unsettled row and reuse
|
|
14
|
+
* never happened);
|
|
13
15
|
* - the roster is known and that session is present and idle.
|
|
14
16
|
*
|
|
15
17
|
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
@@ -37,6 +37,21 @@ export interface LedgerRuntimeView {
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function processState(pid: number): string | undefined;
|
|
39
39
|
export declare function processIsAlive(pid: number): boolean;
|
|
40
|
+
/** Runs one PowerShell script and returns its trimmed stdout. */
|
|
41
|
+
export type PowerShellProbe = (script: string, timeoutMs: number) => string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Windows start time (Unix epoch ms) of a live process.
|
|
44
|
+
*
|
|
45
|
+
* `Get-Process` is the precise first choice, but an unprivileged caller cannot
|
|
46
|
+
* read `.StartTime` for a protected process (System, svchost): the property is
|
|
47
|
+
* empty, so the probe returns nothing. Without a fallback, a crash leftover
|
|
48
|
+
* lock whose PID was reused by such a process could never be proven stale and
|
|
49
|
+
* blocked every startup until the lock was deleted by hand (issue #1629).
|
|
50
|
+
* Win32_Process through CIM reports the same CreationDate for those processes
|
|
51
|
+
* at the same millisecond precision, so it is the second identity source. The
|
|
52
|
+
* probe is injectable so the fallback chain is testable off Windows.
|
|
53
|
+
*/
|
|
54
|
+
export declare function win32StartTimeMs(pid: number, probe?: PowerShellProbe): number | undefined;
|
|
40
55
|
export declare class HostTaskLedger {
|
|
41
56
|
private readonly now;
|
|
42
57
|
private document;
|
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.24",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"@types/node": "^22.20.0",
|
|
69
69
|
"@types/react": "~18.3.1",
|
|
70
70
|
"@types/react-dom": "^18.3.5",
|
|
71
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
71
72
|
"jsdom": "^25.0.0",
|
|
72
73
|
"react": "^18.3.1",
|
|
73
74
|
"react-dom": "^18.3.1",
|
|
@@ -10,6 +10,7 @@ import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecor
|
|
|
10
10
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
11
11
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
12
12
|
import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
|
|
13
|
+
import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
|
|
13
14
|
import css from '../board.module.css'
|
|
14
15
|
|
|
15
16
|
export interface NewTaskModalProps {
|
|
@@ -54,7 +55,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
|
|
|
54
55
|
// parse face; the section stays hidden otherwise.
|
|
55
56
|
const [canParse] = useState(controller.getSnapshot().canParseTask === true)
|
|
56
57
|
const [parseText, setParseText] = useState('')
|
|
57
|
-
|
|
58
|
+
// Issue #1621: start from the model this browser used last, not from the
|
|
59
|
+
// roster's first entry; '' is the Host default and stays valid.
|
|
60
|
+
const [parseModel, setParseModel] = useState(() => readParseModelPreference())
|
|
58
61
|
const [parsePending, setParsePending] = useState(false)
|
|
59
62
|
const [parseError, setParseError] = useState<string | undefined>(undefined)
|
|
60
63
|
const parseAbort = useRef<AbortController | undefined>(undefined)
|
|
@@ -67,10 +70,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
|
|
|
67
70
|
[controller],
|
|
68
71
|
)
|
|
69
72
|
|
|
70
|
-
// The model roster arrives asynchronously
|
|
71
|
-
// the
|
|
73
|
+
// The model roster arrives asynchronously. A remembered model the deployment
|
|
74
|
+
// no longer offers falls back to the Host default; an empty value is the
|
|
75
|
+
// Host-default choice and is never overwritten by the roster (issue #1621).
|
|
72
76
|
useEffect(() => {
|
|
73
|
-
if (parseModel === ''
|
|
77
|
+
if (parseModel === '' || parseModels.length === 0) return
|
|
78
|
+
if (parseModels.some(option => option.id === parseModel)) return
|
|
79
|
+
setParseModel('')
|
|
80
|
+
writeParseModelPreference('')
|
|
74
81
|
}, [parseModel, options.models])
|
|
75
82
|
|
|
76
83
|
const runParse = async (): Promise<void> => {
|
|
@@ -97,7 +104,13 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
|
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Create the task through the Host, then optionally start it.
|
|
109
|
+
* @param runAfterCreate - true for the "create and run" action: the task is
|
|
110
|
+
* committed either way, and a refused start opens the task instead of
|
|
111
|
+
* reporting the creation as failed.
|
|
112
|
+
*/
|
|
113
|
+
const submit = async (runAfterCreate: boolean): Promise<void> => {
|
|
101
114
|
if (scheduleEnabled) {
|
|
102
115
|
const cron = scheduleCron.trim()
|
|
103
116
|
if (cron === '' || !isValidCron(cron)) {
|
|
@@ -156,6 +169,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
|
|
|
156
169
|
await controller.archiveTask(initialTask.id)
|
|
157
170
|
}
|
|
158
171
|
}
|
|
172
|
+
if (runAfterCreate) {
|
|
173
|
+
// The task exists from here on, so a refused start (a permission above
|
|
174
|
+
// the session default, a pinned target that went stale) must not read as
|
|
175
|
+
// a failed creation: open the task, whose detail view owns the
|
|
176
|
+
// confirmation step and the refusal message.
|
|
177
|
+
const started = await controller.runTask(task.id)
|
|
178
|
+
if (!started) controller.openTask(task.id)
|
|
179
|
+
}
|
|
159
180
|
onClose()
|
|
160
181
|
}
|
|
161
182
|
|
|
@@ -173,8 +194,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
|
|
|
173
194
|
error={error}
|
|
174
195
|
pending={pending}
|
|
175
196
|
submitLabel={t('new.submit')}
|
|
176
|
-
onSubmit={() => { void submit() }}
|
|
197
|
+
onSubmit={() => { void submit(false) }}
|
|
177
198
|
onClose={onClose}
|
|
199
|
+
secondaryAction={{ label: t('new.createAndRun'), onSubmit: () => { void submit(true) } }}
|
|
178
200
|
>
|
|
179
201
|
{canParse && (
|
|
180
202
|
<section className={css.aiParse} data-dsh-part="ai-parse">
|
|
@@ -193,8 +215,12 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
|
|
|
193
215
|
className={css.select}
|
|
194
216
|
value={parseModel}
|
|
195
217
|
aria-label={t('new.aiParseModel')}
|
|
196
|
-
onChange={event => {
|
|
218
|
+
onChange={event => {
|
|
219
|
+
setParseModel(event.target.value)
|
|
220
|
+
writeParseModelPreference(event.target.value)
|
|
221
|
+
}}
|
|
197
222
|
>
|
|
223
|
+
<option value="">{t('exec.model.default')}</option>
|
|
198
224
|
{parseModels.map(option => (
|
|
199
225
|
<option key={option.id} value={option.id}>{option.name ?? option.id}</option>
|
|
200
226
|
))}
|
|
@@ -21,6 +21,7 @@ export function ModalShell({
|
|
|
21
21
|
submitLabel,
|
|
22
22
|
onSubmit,
|
|
23
23
|
onClose,
|
|
24
|
+
secondaryAction,
|
|
24
25
|
children,
|
|
25
26
|
}: {
|
|
26
27
|
ariaLabel: string
|
|
@@ -30,6 +31,8 @@ export function ModalShell({
|
|
|
30
31
|
submitLabel: string
|
|
31
32
|
onSubmit: () => void
|
|
32
33
|
onClose: () => void
|
|
34
|
+
/** Optional second action beside the primary submit (e.g. create and run). */
|
|
35
|
+
secondaryAction?: { label: string; onSubmit: () => void }
|
|
33
36
|
children: ReactNode
|
|
34
37
|
}) {
|
|
35
38
|
return (
|
|
@@ -50,6 +53,16 @@ export function ModalShell({
|
|
|
50
53
|
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
51
54
|
{t('new.cancel')}
|
|
52
55
|
</button>
|
|
56
|
+
{secondaryAction !== undefined && (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className={css.ghostButton}
|
|
60
|
+
disabled={pending}
|
|
61
|
+
onClick={secondaryAction.onSubmit}
|
|
62
|
+
>
|
|
63
|
+
{secondaryAction.label}
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
53
66
|
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
54
67
|
{submitLabel}
|
|
55
68
|
</button>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model the user last picked for AI parsing, remembered per browser so the
|
|
3
|
+
* next new-task modal starts from it instead of the roster's first entry
|
|
4
|
+
* (issue #1621). An empty value is a real choice — "let the Host use its
|
|
5
|
+
* default model" — and is remembered as such.
|
|
6
|
+
*/
|
|
7
|
+
const PARSE_MODEL_KEY = 'dsh-task-board.parse-model'
|
|
8
|
+
|
|
9
|
+
/** The nearest browser storage, or undefined when the global itself throws. */
|
|
10
|
+
function defaultStorage(): Storage | undefined {
|
|
11
|
+
try {
|
|
12
|
+
return globalThis.localStorage
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The remembered parse model; '' means the Host default (or no preference). */
|
|
19
|
+
export function readParseModelPreference(storage: Pick<Storage, 'getItem'> | undefined = defaultStorage()): string {
|
|
20
|
+
try {
|
|
21
|
+
return storage?.getItem(PARSE_MODEL_KEY) ?? ''
|
|
22
|
+
} catch {
|
|
23
|
+
// A blocked or full store must never break the form.
|
|
24
|
+
return ''
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Remember the picked parse model; '' records the Host-default choice. */
|
|
29
|
+
export function writeParseModelPreference(model: string, storage: Pick<Storage, 'setItem'> | undefined = defaultStorage()): void {
|
|
30
|
+
try {
|
|
31
|
+
storage?.setItem(PARSE_MODEL_KEY, model)
|
|
32
|
+
} catch {
|
|
33
|
+
// Storage is optional: the picker still works without it.
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardS
|
|
|
30
30
|
import { en, zh, setRuntimeTranslate, type TaskBoardKey } from './locales.ts'
|
|
31
31
|
import { HttpTaskBoardHostTransport } from './host-api.ts'
|
|
32
32
|
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
33
|
+
import { installPluginCard } from './plugin-card-seat.ts'
|
|
33
34
|
|
|
34
35
|
/** Locale namespace this plugin owns. */
|
|
35
36
|
const NS = 'task-board'
|
|
@@ -164,27 +165,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
164
165
|
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
|
|
165
166
|
|
|
166
167
|
// Plugin configuration card: one staged form over the `task-board` settings
|
|
167
|
-
// namespace, contributed to
|
|
168
|
+
// namespace, contributed to whichever plugin-card seat this host declares
|
|
169
|
+
// (issue #1589).
|
|
168
170
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
169
171
|
const settingsScope = binder.bind<TaskBoardSettings>({ namespace: TASK_BOARD_NS })
|
|
170
172
|
const settingsCard = new TaskBoardSettingsCardController(settingsScope)
|
|
171
|
-
ctx
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
inject: () => settingsCard.inject(),
|
|
179
|
-
}, TaskBoardSettingsCard)
|
|
180
|
-
return () => {
|
|
181
|
-
settingsCard.dispose()
|
|
182
|
-
unregister()
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
return () => {}
|
|
186
|
-
}
|
|
173
|
+
installPluginCard(ctx, {
|
|
174
|
+
namespace: TASK_BOARD_NS,
|
|
175
|
+
id: 'task-board',
|
|
176
|
+
order: 110,
|
|
177
|
+
locale: NS,
|
|
178
|
+
inject: () => settingsCard.inject(),
|
|
179
|
+
component: TaskBoardSettingsCard,
|
|
187
180
|
})
|
|
181
|
+
ctx.effect(() => () => { settingsCard.dispose() }, 'task-board: settings card')
|
|
188
182
|
|
|
189
183
|
// The sidebar entry and board view mount once the settings scope settles;
|
|
190
184
|
// while the scope is still loading, the composition default is unknown, so
|
package/src/client/locales.ts
CHANGED
|
@@ -62,6 +62,7 @@ export const zh = {
|
|
|
62
62
|
'new.prompt': '执行 Prompt',
|
|
63
63
|
'new.promptPlaceholder': '发给 agent 的完整指令(留空则使用标题)',
|
|
64
64
|
'new.submit': '创建',
|
|
65
|
+
'new.createAndRun': '创建并执行',
|
|
65
66
|
'new.cancel': '取消',
|
|
66
67
|
'new.required': '标题不能为空',
|
|
67
68
|
'new.freeze': '冻结快照(可选)',
|
|
@@ -252,6 +253,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
252
253
|
'new.prompt': 'Run Prompt',
|
|
253
254
|
'new.promptPlaceholder': 'The full instruction sent to the agent (title is used when blank)',
|
|
254
255
|
'new.submit': 'Create',
|
|
256
|
+
'new.createAndRun': 'Create and run',
|
|
255
257
|
'new.cancel': 'Cancel',
|
|
256
258
|
'new.required': 'Title is required',
|
|
257
259
|
'new.freeze': 'Frozen snapshot (optional)',
|