@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/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 and has settled (an open
185
- * execution would have made the ledger refuse the run in the first place);
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
- const last = task.executions[task.executions.length - 1];
198
- if (last === void 0 || last.sessionId === void 0 || last.endedAt === void 0) return void 0;
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;
@@ -61,6 +61,7 @@ export declare const zh: {
61
61
  'new.prompt': string;
62
62
  'new.promptPlaceholder': string;
63
63
  'new.submit': string;
64
+ 'new.createAndRun': string;
64
65
  'new.cancel': string;
65
66
  'new.required': string;
66
67
  'new.freeze': string;
@@ -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 and has settled (an open
12
- * execution would have made the ledger refuse the run in the first place);
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.23",
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
- const [parseModel, setParseModel] = useState('')
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: default to its first entry, which
71
- // the user can change before parsing.
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 === '' && parseModels.length > 0) setParseModel(parseModels[0]!.id)
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
- const submit = async (): Promise<void> => {
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 => { setParseModel(event.target.value) }}
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
+ }
@@ -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 the Web UI plugin group.
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.slots.inject('web-ui.plugin.item', () => {
172
- try {
173
- const unregister = ctx.slots.register({
174
- name: 'web-ui.plugin.item',
175
- id: 'task-board',
176
- order: 110,
177
- locale: NS,
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
@@ -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)',