@linxin666/dsh-client-ui-task-board 0.3.22 → 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.
Files changed (42) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +2 -0
  3. package/README.zh.md +2 -0
  4. package/lib/client.js +726 -71
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2619 -175
  7. package/lib/types/client/board/EditTaskModal.d.ts +6 -0
  8. package/lib/types/client/board/NewTaskModal.d.ts +6 -1
  9. package/lib/types/client/board/TaskBoard.d.ts +2 -0
  10. package/lib/types/client/board/TaskForm.d.ts +8 -3
  11. package/lib/types/client/board/parse-model-pref.d.ts +4 -0
  12. package/lib/types/client/host-api.d.ts +23 -1
  13. package/lib/types/client/locales.d.ts +26 -0
  14. package/lib/types/client/plugin-card-seat.d.ts +96 -0
  15. package/lib/types/client/sidebar-entry-core.d.ts +32 -0
  16. package/lib/types/core/controller.d.ts +35 -1
  17. package/lib/types/core/session-reuse.d.ts +4 -2
  18. package/lib/types/host-ai.d.ts +51 -0
  19. package/lib/types/host-ledger.d.ts +15 -0
  20. package/lib/types/host-routes.d.ts +11 -1
  21. package/lib/types/index.d.ts +9 -0
  22. package/lib/types/protocol.d.ts +19 -0
  23. package/package.json +2 -1
  24. package/src/client/board/EditTaskModal.tsx +36 -0
  25. package/src/client/board/NewTaskModal.tsx +119 -5
  26. package/src/client/board/TaskBoard.tsx +89 -0
  27. package/src/client/board/TaskDetail.tsx +38 -2
  28. package/src/client/board/TaskForm.tsx +17 -11
  29. package/src/client/board/parse-model-pref.ts +35 -0
  30. package/src/client/board.module.css +61 -0
  31. package/src/client/host-api.ts +124 -5
  32. package/src/client/index.ts +18 -17
  33. package/src/client/locales.ts +52 -0
  34. package/src/client/plugin-card-seat.ts +185 -0
  35. package/src/client/sidebar-entry-core.ts +96 -12
  36. package/src/core/controller.ts +57 -1
  37. package/src/core/session-reuse.ts +14 -5
  38. package/src/host-ai.ts +152 -0
  39. package/src/host-ledger.ts +84 -14
  40. package/src/host-routes.ts +68 -3
  41. package/src/index.ts +26 -1
  42. package/src/protocol.ts +34 -0
@@ -6,3 +6,9 @@ export declare function EditTaskModal({ controller, task, onClose }: {
6
6
  task: TaskRecord;
7
7
  onClose: () => void;
8
8
  }): import("react").JSX.Element;
9
+ /** Edit-tags modal: edit labels only, shown for tasks after first execution. */
10
+ export declare function EditTagsModal({ controller, task, onClose }: {
11
+ controller: BoardController;
12
+ task: TaskRecord;
13
+ onClose: () => void;
14
+ }): import("react").JSX.Element;
@@ -5,8 +5,13 @@ export interface NewTaskModalProps {
5
5
  onClose: () => void;
6
6
  /** Optional task template to clone/duplicate from. */
7
7
  initialTask?: TaskRecord;
8
+ /**
9
+ * Workspace the board's project filter has selected (#1536): a task created
10
+ * while a project is open belongs to that project unless the user changes it.
11
+ */
12
+ defaultWorkspaceId?: string;
8
13
  /** Optional callback after successful duplication (e.g. to archive source). */
9
14
  onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>;
10
15
  }
11
16
  /** New-task form overlay. */
12
- export declare function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps): import("react").JSX.Element;
17
+ export declare function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps): import("react").JSX.Element;
@@ -1,5 +1,7 @@
1
1
  import { type BoardController } from '../../core/controller.ts';
2
2
  import { type TaskRecord } from '../../core/tasks.ts';
3
+ /** Sentinel option value of the project row's "register a new project" entry. */
4
+ export declare const NEW_PROJECT_VALUE = "__dsh_new_project__";
3
5
  /** Case-insensitive title/description/tag/freeze-snapshot match. */
4
6
  export declare function matchesFilter(task: TaskRecord, filter: string): boolean;
5
7
  /**
@@ -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. */
@@ -40,7 +45,7 @@ export declare function TaskTagFields({ tags, knownTags, onChange, }: {
40
45
  onChange: (tags: TaskTag[]) => void;
41
46
  }): import("react").JSX.Element;
42
47
  /**
43
- * Drop blank rows and trim what survives, so the wire always carries a valid
44
- * tag list (the protocol rejects a list with a blank name or an empty array).
48
+ * Clean a tag list via normalizeTags: trim, drop blanks and duplicates, cap
49
+ * lengths and count, so the wire always carries a valid tag list.
45
50
  */
46
51
  export declare function cleanTags(tags: readonly TaskTag[]): TaskTag[];
@@ -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;
@@ -1,10 +1,26 @@
1
1
  import type { TaskRecord } from '../core/tasks.ts';
2
- import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from '../protocol.ts';
2
+ import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardParseDraft, type TaskBoardParseRequest, type TaskBoardSnapshot } from '../protocol.ts';
3
+ /**
4
+ * Failure classes of the Host API, in the language the panel renders
5
+ * (issue #1528). The Host is the only part that knows whether a task-board
6
+ * route exists at all, so a raw fetch/parse error must never reach the user:
7
+ * "Unexpected token 'o', \"not found\" is not valid JSON" is what a missing
8
+ * Host half looks like today.
9
+ */
10
+ export type HostApiFailure = 'not-mounted' | 'unauthorized' | 'locked' | 'rejected' | 'timeout' | 'unreachable' | 'unexpected';
11
+ /** Transport failure carrying a stable class next to its user-facing message. */
12
+ export declare class HostApiError extends Error {
13
+ readonly failure: HostApiFailure;
14
+ readonly status?: number | undefined;
15
+ constructor(failure: HostApiFailure, message: string, status?: number | undefined);
16
+ }
3
17
  export interface TaskBoardHostTransport {
4
18
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
5
19
  state(): Promise<TaskBoardSnapshot>;
6
20
  action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>;
7
21
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
22
+ /** One-shot model parse of pasted text (issue #1540). */
23
+ parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
8
24
  }
9
25
  export declare class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
10
26
  private readonly storage;
@@ -14,5 +30,11 @@ export declare class HttpTaskBoardHostTransport implements TaskBoardHostTranspor
14
30
  action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>;
15
31
  private post;
16
32
  private request;
33
+ /**
34
+ * Ask the Host to turn pasted text into task fields. The route answers a
35
+ * typed failure (no model, timeout, unparseable reply) already phrased for
36
+ * the form, so the UI never renders a raw status code (issue #1540).
37
+ */
38
+ parseDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
17
39
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
18
40
  }
@@ -28,6 +28,30 @@ export declare const zh: {
28
28
  'board.updated': string;
29
29
  'board.created': string;
30
30
  'board.hostError': string;
31
+ 'board.hostError.notMounted': string;
32
+ 'board.hostError.unauthorized': string;
33
+ 'board.hostError.locked': string;
34
+ 'board.hostError.timeout': string;
35
+ 'board.hostError.unreachable': string;
36
+ 'board.hostError.unexpected': string;
37
+ 'board.project': string;
38
+ 'board.projectAll': string;
39
+ 'board.projectNew': string;
40
+ 'board.projectNewPath': string;
41
+ 'board.projectNewPathPlaceholder': string;
42
+ 'board.projectCreate': string;
43
+ 'board.projectCreateFailed': string;
44
+ 'new.aiParse': string;
45
+ 'new.aiParseHint': string;
46
+ 'new.aiParsePlaceholder': string;
47
+ 'new.aiParseModel': string;
48
+ 'new.aiParseRun': string;
49
+ 'new.aiParseCancel': string;
50
+ 'new.aiParseEmpty': string;
51
+ 'new.aiParseNoModel': string;
52
+ 'new.aiParseUnavailable': string;
53
+ 'new.aiParseTimeout': string;
54
+ 'new.aiParseFailed': string;
31
55
  'board.retryHost': string;
32
56
  'board.hostMeta': string;
33
57
  'new.title': string;
@@ -37,6 +61,7 @@ export declare const zh: {
37
61
  'new.prompt': string;
38
62
  'new.promptPlaceholder': string;
39
63
  'new.submit': string;
64
+ 'new.createAndRun': string;
40
65
  'new.cancel': string;
41
66
  'new.required': string;
42
67
  'new.freeze': string;
@@ -70,6 +95,7 @@ export declare const zh: {
70
95
  'detail.permissionConfirmed': string;
71
96
  'detail.title': string;
72
97
  'detail.edit': string;
98
+ 'detail.editTags': string;
73
99
  'detail.duplicate': string;
74
100
  'detail.duplicateAndEdit': string;
75
101
  'detail.close': 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
  /**
@@ -14,7 +14,7 @@
14
14
  import type { TaskStore } from './store.ts';
15
15
  import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
16
16
  import { type TaskUpdatePatch } from './use-cases/task-update.ts';
17
- import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts';
17
+ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardParseDraft, TaskBoardParseRequest, TaskBoardSnapshot } from '../protocol.ts';
18
18
  export interface TaskBoardTransport {
19
19
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>;
20
20
  state(): Promise<TaskBoardSnapshot>;
@@ -24,6 +24,11 @@ export interface TaskBoardTransport {
24
24
  */
25
25
  action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>;
26
26
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void;
27
+ /**
28
+ * One-shot model parse of pasted text (issue #1540). Optional: a deployment
29
+ * that cannot parse simply omits it, and the form hides the section.
30
+ */
31
+ parseDraft?(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
27
32
  }
28
33
  /** The sessions face the controller needs for navigation awareness. */
29
34
  export interface SessionsControllerFace {
@@ -47,6 +52,14 @@ export interface ControllerDeps {
47
52
  /** Host-authoritative transport; absent keeps the legacy in-memory test path. */
48
53
  transport?: TaskBoardTransport;
49
54
  }
55
+ /**
56
+ * Register one host directory as a DSH project (workspace). Wired by the
57
+ * browser apply() to the runtime's workspace controller; without it the board
58
+ * hides its "new project" action instead of offering a dead control (#1536).
59
+ */
60
+ export type WorkspaceCreator = (path: string) => Promise<{
61
+ workspaceId: string;
62
+ }>;
50
63
  /** One workspace option the execution-target pickers offer. */
51
64
  export interface ExecutionWorkspaceOption {
52
65
  workspaceId: string;
@@ -85,6 +98,10 @@ export interface ControllerSnapshot {
85
98
  /** Picker option sets (workspace list + agent-preset roster). */
86
99
  executionOptions: ExecutionOptionsSnapshot;
87
100
  pendingTaskIds: readonly string[];
101
+ /** Whether the board may offer "register a new project" (issue #1536). */
102
+ canCreateWorkspace?: boolean;
103
+ /** Whether this deployment can parse pasted text into task fields (issue #1540). */
104
+ canParseTask?: boolean;
88
105
  transportError?: string;
89
106
  host?: Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission'>;
90
107
  }
@@ -101,6 +118,7 @@ export declare class BoardController {
101
118
  private archiveView;
102
119
  private selectedTaskId;
103
120
  private executionOptions;
121
+ private workspaceCreator;
104
122
  private listeners;
105
123
  private disposers;
106
124
  private readonly now;
@@ -151,6 +169,22 @@ export declare class BoardController {
151
169
  * and agent-preset roster come from the runtime, not the ledger).
152
170
  */
153
171
  setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void;
172
+ /** Wire (or clear) the runtime's project registration face (issue #1536). */
173
+ setWorkspaceCreator(creator: WorkspaceCreator | undefined): void;
174
+ /**
175
+ * Register an existing host directory as a DSH project, exactly as the GUI's
176
+ * own "add project" does; the runtime's failure message surfaces unchanged.
177
+ */
178
+ createWorkspace(path: string): Promise<{
179
+ workspaceId: string;
180
+ }>;
181
+ /** Whether this deployment can parse pasted text into task fields (issue #1540). */
182
+ canParseTask(): boolean;
183
+ /**
184
+ * Parse pasted text into task fields through the Host. The transport already
185
+ * phrases every failure for the user, so its message surfaces unchanged.
186
+ */
187
+ parseTaskDraft(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
154
188
  moveTask(id: string, status: TaskStatus): void;
155
189
  deleteTask(id: string): void;
156
190
  /**
@@ -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
@@ -0,0 +1,51 @@
1
+ /**
2
+ * One-shot model parse behind the board's "parse pasted text" action
3
+ * (issue #1540).
4
+ *
5
+ * The browser half never talks to a model: it posts the pasted text and the
6
+ * route the user picked to the Host, which calls the injected `llm` service
7
+ * once and answers with the three draft fields the new-task form accepts. The
8
+ * reply is untrusted text, so extraction is defensive: code fences are
9
+ * stripped, the first JSON object wins, and an unusable reply falls back to
10
+ * the user's own words instead of an empty form.
11
+ */
12
+ import { type LlmRuntime } from '@deepseek-ai/dsh-llm';
13
+ import type { TaskBoardParseDraft, TaskBoardParseRequest } from './protocol.ts';
14
+ /** How long one parse may take before the Host gives up on the model. */
15
+ export declare const TASK_PARSE_TIMEOUT_MS = 45000;
16
+ /** Largest pasted text one parse accepts (bytes). */
17
+ export declare const TASK_PARSE_MAX_INPUT: number;
18
+ export type TaskParseFailureCode = 'no-model' | 'model-error' | 'parse-failed' | 'timeout';
19
+ /** Typed parse failure; the route maps the code onto a status and a body. */
20
+ export declare class TaskParseError extends Error {
21
+ readonly code: TaskParseFailureCode;
22
+ constructor(code: TaskParseFailureCode, message: string);
23
+ }
24
+ /** Split a qualified `provider/model` route the same way the runner does. */
25
+ export declare function splitModelRoute(qualified: string | undefined): {
26
+ provider: string;
27
+ model: string;
28
+ } | undefined;
29
+ /**
30
+ * Read the draft out of a model reply. Fences and surrounding prose are
31
+ * tolerated; anything unusable returns undefined so the caller can decide
32
+ * between a typed failure and a fallback.
33
+ * @param reply - raw model text.
34
+ */
35
+ export declare function extractTaskParseReply(reply: string): Partial<TaskBoardParseDraft> | undefined;
36
+ /**
37
+ * Fill the three form fields, falling back to the pasted text so a weak model
38
+ * reply never loses what the user actually wrote.
39
+ * @param reply - raw model text.
40
+ * @param source - the text the user pasted.
41
+ */
42
+ export declare function draftFromReply(reply: string, source: string): TaskBoardParseDraft;
43
+ /**
44
+ * Parse one pasted text through the injected `llm` service.
45
+ * @param llm - the Host's llm service.
46
+ * @param request - pasted text plus the qualified model route.
47
+ * @param signal - caller cancellation (client disconnect); combined with the timeout.
48
+ * @returns the draft fields.
49
+ * @throws TaskParseError with a code the route maps onto a status.
50
+ */
51
+ export declare function parseTaskDraft(llm: LlmRuntime, request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>;
@@ -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;
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
3
3
  import type { TaskBoardHostService } from './host-service.ts';
4
+ import { type TaskBoardParseDraft, type TaskBoardParseRequest } from './protocol.ts';
4
5
  /** Header replaced by an authenticated same-host reverse proxy. */
5
6
  export declare const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
6
7
  /** Optional authenticated reverse-proxy access layered over the loopback default. */
@@ -8,6 +9,15 @@ export interface TaskBoardRouteAccess {
8
9
  trustedProxyHosts?: readonly string[];
9
10
  proxyToken?: string;
10
11
  }
12
+ /**
13
+ * Host faces the routes need beyond the ledger service. The parse face is
14
+ * resolved lazily by the caller, so a deployment without an llm service still
15
+ * registers the route and answers with a typed "no model" failure instead of
16
+ * leaving the panel with an unmounted path (issue #1540).
17
+ */
18
+ export interface TaskBoardRouteOptions {
19
+ parseTask?: (request: TaskBoardParseRequest, signal: AbortSignal) => Promise<TaskBoardParseDraft>;
20
+ }
11
21
  interface ResolvedRouteAccess {
12
22
  trustedProxyHosts: ReadonlySet<string>;
13
23
  proxyToken?: string;
@@ -22,5 +32,5 @@ interface ResolvedRouteAccess {
22
32
  * internal token header after their own authentication step.
23
33
  */
24
34
  export declare function isTrustedTaskBoardRequest(req: IncomingMessage, access: ResolvedRouteAccess): boolean;
25
- export declare function makeTaskBoardRoutes(service: TaskBoardHostService, access?: TaskBoardRouteAccess): WebRoute[];
35
+ export declare function makeTaskBoardRoutes(service: TaskBoardHostService, access?: TaskBoardRouteAccess, options?: TaskBoardRouteOptions): WebRoute[];
26
36
  export {};
@@ -8,6 +8,7 @@
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
9
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
10
10
  import z from 'schemastery';
11
+ import type { LlmRuntime } from '@deepseek-ai/dsh-llm';
11
12
  import { type TaskPermission } from './core/tasks.ts';
12
13
  /** Default environment variable holding the authenticated proxy token. */
13
14
  export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
@@ -49,6 +50,14 @@ export declare function resolveProxyAccess(config: Config | undefined, env?: Nod
49
50
  trustedProxyHosts: string[];
50
51
  proxyToken?: string;
51
52
  };
53
+ /**
54
+ * Read the optional `llm` service. The board deliberately does not inject it:
55
+ * a deployment without a model must still mount the board, and the parse route
56
+ * answers a typed failure instead of the plugin failing to load (issue #1540).
57
+ * @param ctx - the plugin context.
58
+ * @returns the llm service, or undefined when this deployment serves none.
59
+ */
60
+ export declare function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined;
52
61
  /**
53
62
  * Register the board's announcement section, gated on the composition entry's
54
63
  * `announceToAgent` (and the live settings value once the web settings
@@ -36,6 +36,25 @@ export interface TaskBoardEventPayload {
36
36
  scheduler: TaskBoardSchedulerSnapshot;
37
37
  power: TaskBoardPowerSnapshot;
38
38
  }
39
+ /**
40
+ * Request body of `POST {TASK_BOARD_API_PREFIX}/parse` (issue #1540): the text
41
+ * the user pasted plus the `provider/model` route that should parse it.
42
+ */
43
+ export interface TaskBoardParseRequest {
44
+ text: string;
45
+ /** Qualified route from the model picker; absent when no route was chosen. */
46
+ model?: string;
47
+ }
48
+ /** Draft fields a parse returns; the user reviews them before creating the task. */
49
+ export interface TaskBoardParseDraft {
50
+ title: string;
51
+ description: string;
52
+ prompt: string;
53
+ }
54
+ /** Strict parse of a `/parse` request body; undefined rejects the request. */
55
+ export declare function parseTaskParseRequest(value: unknown): TaskBoardParseRequest | undefined;
56
+ /** Whether a decoded reply carries the three draft strings the form accepts. */
57
+ export declare function isTaskParseDraft(value: unknown): value is TaskBoardParseDraft;
39
58
  export type TaskBoardAction = {
40
59
  kind: 'import';
41
60
  sourceId: string;
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.22",
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",
@@ -69,3 +69,39 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
69
69
  </ModalShell>
70
70
  )
71
71
  }
72
+
73
+ /** Edit-tags modal: edit labels only, shown for tasks after first execution. */
74
+ export function EditTagsModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
75
+ const [tags, setTags] = useState<TaskTag[]>(task.tags ?? [])
76
+ const [error, setError] = useState<string | undefined>(undefined)
77
+ const [pending, setPending] = useState(false)
78
+
79
+ const submit = async (): Promise<void> => {
80
+ setPending(true)
81
+ const tagList = cleanTags(tags)
82
+ const patch = {
83
+ tags: tagList.length > 0 ? tagList : null,
84
+ }
85
+ if (await controller.updateTask(task.id, patch)) {
86
+ onClose()
87
+ return
88
+ }
89
+ setPending(false)
90
+ setError(controller.getSnapshot().transportError ?? t('new.required'))
91
+ }
92
+
93
+ return (
94
+ <ModalShell
95
+ ariaLabel={t('detail.editTags')}
96
+ title={t('detail.editTags')}
97
+ error={error}
98
+ pending={pending}
99
+ submitLabel={t('edit.save')}
100
+ onSubmit={() => { void submit() }}
101
+ onClose={onClose}
102
+ >
103
+ <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
104
+ </ModalShell>
105
+ )
106
+ }
107
+