@linxin666/dsh-client-ui-task-board 0.1.15 → 0.1.17

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
@@ -5,7 +5,7 @@ import z from "schemastery";
5
5
  const SECTION_ORDER = 200;
6
6
  const inject = ["systemPrompt"];
7
7
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
8
- const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;任务可真实执行(驱动 agent 会话);任务支持 5 段 cron 定时执行(如 0 23 * * *);数据存浏览器 localStorage(键 dsh.taskBoard.v1)。限制:定时调度在浏览器端,需 GUI 标签页打开,错过即跳过;执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。";
8
+ const TASK_BOARD_GUIDANCE = "本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;任务可真实执行(驱动 agent 会话);任务可钉住执行目标——工作区 / 模式(agent 预设)/ 权限(read-only / workspace-write / danger-full-access),缺省用运行时默认;任务支持 5 段 cron 定时执行(如 0 23 * * *);数据存浏览器 localStorage(键 dsh.taskBoard.v1)。限制:定时调度在浏览器端,需 GUI 标签页打开,错过即跳过;执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。";
9
9
  /**
10
10
  * Settings namespace of the board's announcement capability — the section the
11
11
  * web settings surface edits. Spelled here rather than imported: the browser
@@ -72,6 +72,20 @@ export declare const zh: {
72
72
  'detail.schedule.notScheduled': string;
73
73
  'detail.schedule.dueSoon': string;
74
74
  'card.scheduled': string;
75
+ 'new.workspace': string;
76
+ 'new.mode': string;
77
+ 'new.permission': string;
78
+ 'exec.workspace.recent': string;
79
+ 'exec.mode.default': string;
80
+ 'exec.mode.defaultSuffix': string;
81
+ 'exec.mode.brokenSuffix': string;
82
+ 'exec.mode.removed': string;
83
+ 'exec.permission.default': string;
84
+ 'exec.permission.read-only': string;
85
+ 'exec.permission.workspace-write': string;
86
+ 'exec.permission.danger-full-access': string;
87
+ 'detail.executionSettings': string;
88
+ 'exec.hint': string;
75
89
  'settings.title': string;
76
90
  'settings.description': string;
77
91
  'settings.enabled': string;
@@ -18,6 +18,14 @@ export type FieldWrite = {
18
18
  export interface FieldSpec {
19
19
  /** Field name inside the namespace section. */
20
20
  field: string;
21
+ /**
22
+ * Whether the Host treats this field as a secret and redacts its value from
23
+ * the read-back (role('secret') in the section schema). Redacted secrets are
24
+ * never compared against the draft on save; the field lands when the scope
25
+ * reports the write succeeded (its secret-set marker under the bridge), so
26
+ * a successful secret save is not misreported as failed.
27
+ */
28
+ secret?: boolean;
21
29
  /** Render a stored value as draft text; the empty string when the section carries none. */
22
30
  format: (value: unknown) => string;
23
31
  /**
@@ -57,6 +65,12 @@ export interface CardShell {
57
65
  saving: boolean;
58
66
  /** Whether the last save did not land as staged; cleared by the next edit or save. */
59
67
  failed: boolean;
68
+ /**
69
+ * The rejection code/message the Host returned for the last failed save,
70
+ * surfaced next to the generic failure text. Undefined while no save has
71
+ * failed (or the failure carried no server reason).
72
+ */
73
+ failedReason?: string;
60
74
  }
61
75
  /** The write actions the card's slot entry injects. */
62
76
  export interface CardActions {
@@ -69,6 +83,37 @@ export interface CardActions {
69
83
  /** Drop every staged edit. */
70
84
  discard: () => void;
71
85
  }
86
+ /** One durable write a batched settings scope performs. */
87
+ export interface BatchedWrite {
88
+ /** Field this entry writes. */
89
+ field: string;
90
+ /** set stores a value; unset drops the leaf. */
91
+ op: 'set' | 'unset';
92
+ /** Value for op set (absent for unset). */
93
+ value?: unknown;
94
+ }
95
+ /** Per-field outcome of one batched scope write. */
96
+ export interface BatchedFieldResult {
97
+ /** Field this entry writes. */
98
+ field: string;
99
+ /** Whether the Host accepted this field's write (per the read-back view). */
100
+ landed: boolean;
101
+ }
102
+ /**
103
+ * Result of a batched scope write. The bridge scope posts every planned write
104
+ * in one /mutate so the Host validate hook judges baseURL+model together; a
105
+ * batched refusal fails the whole save rather than per-field.
106
+ */
107
+ export interface BatchResult {
108
+ /** Whether the whole mutate was accepted. */
109
+ ok: boolean;
110
+ /** Per-field success, in the request order (always present when ok). */
111
+ fields: BatchedFieldResult[];
112
+ /** Host rejection code (mutate refused). */
113
+ code?: string;
114
+ /** Host rejection message (mutate refused). */
115
+ message?: string;
116
+ }
72
117
  /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
73
118
  export interface NumberConstraints {
74
119
  /** The accepted value must be a whole number. */
@@ -80,6 +125,13 @@ export interface NumberConstraints {
80
125
  export declare function numberField(field: string, constraints?: NumberConstraints): FieldSpec;
81
126
  /** A free-text field. An empty draft clears the field. */
82
127
  export declare function textField(field: string): FieldSpec;
128
+ /**
129
+ * A free-text field the Host treats as a secret and redacts from the read-back
130
+ * (role('secret') in the section schema). The card still edits it like text,
131
+ * but a save never compares the redacted value back and relies on the scope
132
+ * reporting the write landed.
133
+ */
134
+ export declare function secretField(field: string): FieldSpec;
83
135
  /** A boolean field, edited through true/false draft text. */
84
136
  export declare function booleanField(field: string): FieldSpec;
85
137
  /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
@@ -99,6 +151,7 @@ export declare class CardForm<T> {
99
151
  private readonly listeners;
100
152
  private saving;
101
153
  private failed;
154
+ private failedReason;
102
155
  /** @param scope - the bound settings scope for this card's namespace. */
103
156
  constructor(scope: SettingsScope<T>, specs: FieldSpec[]);
104
157
  /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
@@ -111,9 +164,18 @@ export declare class CardForm<T> {
111
164
  actions(): CardActions;
112
165
  /**
113
166
  * Write every staged edit, then re-seed from what the Host accepted.
167
+ *
168
+ * When the scope carries the optional batch surface (the dsh-web-ui
169
+ * bridge scope), every planned write rides one mutation so cross-field
170
+ * validate hooks (baseURL+model) judge the batch as a unit instead of
171
+ * deadlocking on per-field writes. Otherwise the per-field loop runs.
172
+ * A field lands only when the Host reports it held the staged value; a
173
+ * landed field's draft is dropped, a failed one stays staged for the user.
114
174
  * @returns settlement after every write and the read-back.
115
175
  */
116
176
  save(): Promise<void>;
177
+ /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
178
+ private batchedScope;
117
179
  /**
118
180
  * Every staged edit a save would write. An entry whose draft is not a value
119
181
  * its field accepts carries no write: the form is still dirty, and the save
@@ -16,6 +16,7 @@
16
16
  import { ExecutionService } from './execution.ts';
17
17
  import type { TaskStore } from './store.ts';
18
18
  import { type NewTaskInput, type TaskRecord, type TaskStatus } from './tasks.ts';
19
+ import { type TaskUpdatePatch } from './use-cases/task-update.ts';
19
20
  /** The sessions face the controller needs for navigation awareness. */
20
21
  export interface SessionsControllerFace {
21
22
  list: {
@@ -39,11 +40,33 @@ export interface ControllerDeps {
39
40
  /** Debounce (ms) for session-list-changed reconciles; defaults to 350. */
40
41
  reconcileDebounceMs?: number;
41
42
  }
43
+ /** One workspace option the execution-target pickers offer. */
44
+ export interface ExecutionWorkspaceOption {
45
+ workspaceId: string;
46
+ /** Display label (workspace title; the wiring falls back to the path). */
47
+ title: string;
48
+ }
49
+ /** One agent-preset option the execution-target pickers offer. */
50
+ export interface ExecutionPresetOption {
51
+ id: string;
52
+ name?: string;
53
+ description?: string;
54
+ /** Why this preset cannot compose a session; the pickers disable it. */
55
+ broken?: string;
56
+ isDefault: boolean;
57
+ }
58
+ /** The execution-target option sets the UI feeds into the controller. */
59
+ export interface ExecutionOptionsSnapshot {
60
+ workspaces: readonly ExecutionWorkspaceOption[];
61
+ presets: readonly ExecutionPresetOption[];
62
+ }
42
63
  /** Immutable controller snapshot for UI subscriptions. */
43
64
  export interface ControllerSnapshot {
44
65
  tasks: readonly TaskRecord[];
45
66
  boardOpen: boolean;
46
67
  selectedTaskId: string | undefined;
68
+ /** Picker option sets (workspace list + agent-preset roster). */
69
+ executionOptions: ExecutionOptionsSnapshot;
47
70
  }
48
71
  /** The selected task (resolved from the ledger), or undefined. */
49
72
  export declare function selectedTaskOf(snapshot: ControllerSnapshot): TaskRecord | undefined;
@@ -56,6 +79,7 @@ export declare class BoardController {
56
79
  private tasks;
57
80
  private boardOpen;
58
81
  private selectedTaskId;
82
+ private executionOptions;
59
83
  private listeners;
60
84
  private disposers;
61
85
  private readonly now;
@@ -74,7 +98,12 @@ export declare class BoardController {
74
98
  openTask(id: string): void;
75
99
  closeTask(): void;
76
100
  createTask(input: NewTaskInput): TaskRecord | undefined;
77
- updateTask(id: string, patch: Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>): void;
101
+ updateTask(id: string, patch: TaskUpdatePatch): void;
102
+ /**
103
+ * Replace (a part of) the picker option sets the UI feeds (workspace list
104
+ * and agent-preset roster come from the runtime, not the ledger).
105
+ */
106
+ setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void;
78
107
  moveTask(id: string, status: TaskStatus): void;
79
108
  deleteTask(id: string): void;
80
109
  /**
@@ -14,22 +14,40 @@
14
14
  * so tests drive it with plain fakes.
15
15
  */
16
16
  import type { ExecutionRecord, TaskRecord } from './tasks.ts';
17
+ /** One list-row summary the execution service reads (the narrow slice of a SessionSummary). */
18
+ export interface ExecutionSessionSummary {
19
+ running: boolean;
20
+ completed?: boolean;
21
+ /** Empty-log bit: the preset can only be recomposed while the session is blank. */
22
+ blank?: boolean;
23
+ /** The preset the session currently runs (absent on deployments without presets). */
24
+ agentPreset?: string;
25
+ }
17
26
  /** The narrow sessions face the service needs. */
18
27
  export interface SessionsExecutionFace {
19
28
  list: {
20
29
  getSnapshot(): {
21
30
  /** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
22
31
  phase: 'pending' | 'ready';
23
- byId: Record<string, {
24
- running: boolean;
25
- completed?: boolean;
26
- }>;
32
+ byId: Record<string, ExecutionSessionSummary>;
27
33
  };
28
34
  subscribe(fn: () => void): () => void;
29
35
  };
30
36
  binding(id: string): {
31
37
  session: SessionDriver;
32
38
  } | undefined;
39
+ /** Record a host-confirmed preset switch so the list label moves immediately. */
40
+ noteAgentPreset?(sessionId: string, agentPreset: string): void;
41
+ }
42
+ /** The narrow agent-preset wire face the service needs (`agentPreset.select`). */
43
+ export interface PresetsExecutionFace {
44
+ /** Recompose a blank session's agent from a preset. */
45
+ select(sessionId: string, agentPreset: string): Promise<{
46
+ ok: true;
47
+ } | {
48
+ ok: false;
49
+ error: unknown;
50
+ }>;
33
51
  }
34
52
  /** The narrow workspaces face the service needs. */
35
53
  export interface WorkspacesExecutionFace {
@@ -63,6 +81,18 @@ export interface SessionDriver {
63
81
  ok: false;
64
82
  error: unknown;
65
83
  }>;
84
+ /**
85
+ * Admit one slash-command line against the session's agent (the
86
+ * `/permission <id>` mechanism). `matched` reports whether a command
87
+ * claimed the line.
88
+ */
89
+ command(line: string): Promise<{
90
+ ok: true;
91
+ matched: boolean;
92
+ } | {
93
+ ok: false;
94
+ error: unknown;
95
+ }>;
66
96
  getSnapshot(): {
67
97
  running: boolean;
68
98
  lastAgentError: string | null;
@@ -74,6 +104,8 @@ export interface SessionDriver {
74
104
  export interface ExecutionEnvironment {
75
105
  sessions: SessionsExecutionFace;
76
106
  workspaces: WorkspacesExecutionFace;
107
+ /** Agent-preset wire face; absent on deployments without preset support. */
108
+ presets?: PresetsExecutionFace;
77
109
  /** Raw-history reader for failure detection of never-opened sessions. */
78
110
  history?: HistoryExecutionFace;
79
111
  }
@@ -104,6 +136,19 @@ export declare class ExecutionService {
104
136
  /** @param env - the runtime faces (real or fake). */
105
137
  constructor(env: ExecutionEnvironment);
106
138
  run(task: TaskRecord, execution: ExecutionRecord, onEvent: (event: ExecutionEvent) => void): Promise<void>;
139
+ /**
140
+ * Recompose the execution session's agent from the task-pinned preset.
141
+ * No-op when the task pins none or the session already runs it; fails the
142
+ * run when the session is no longer blank, the preset face is missing, or
143
+ * the wire refuses.
144
+ */
145
+ private applyMode;
146
+ /**
147
+ * Apply the task-pinned permission preset through the `/permission <id>`
148
+ * slash command. No-op when the task pins none; fails the run when the
149
+ * admission is rejected or no command claimed the line.
150
+ */
151
+ private applyPermission;
107
152
  /**
108
153
  * Inspect a reloaded/background task that was left 'running' and emit a
109
154
  * settled event when its session already finished.
@@ -1,4 +1,4 @@
1
- import type { TaskRecord } from './tasks.ts';
1
+ import { type TaskRecord } from './tasks.ts';
2
2
  /** Persistence seam for the task ledger. */
3
3
  export interface TaskStore {
4
4
  /** Read the persisted ledger (empty when nothing is stored yet). */
@@ -60,12 +60,39 @@ export interface TaskRecord {
60
60
  executions: ExecutionRecord[];
61
61
  /** Optional scheduled-run rule (absent on tasks without a schedule). */
62
62
  schedule?: ScheduleRule;
63
+ /**
64
+ * Workspace the execution must run in (a workspace-list id); absent means
65
+ * the recent-workspace fallback at execution time.
66
+ */
67
+ workspaceId?: string;
68
+ /**
69
+ * Agent preset the execution session must be composed from (an
70
+ * `agentPreset.list` id); absent means the deployment default.
71
+ */
72
+ mode?: string;
73
+ /**
74
+ * Permission preset applied to the execution session through the
75
+ * `/permission <id>` slash command; absent leaves the session default.
76
+ */
77
+ permission?: TaskPermission;
63
78
  }
79
+ /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
80
+ export declare const TASK_PERMISSIONS: readonly ["read-only", "workspace-write", "danger-full-access"];
81
+ /** One permission preset id. */
82
+ export type TaskPermission = typeof TASK_PERMISSIONS[number];
83
+ /** Whether an unknown value is a known permission preset id. */
84
+ export declare function isTaskPermission(value: unknown): value is TaskPermission;
64
85
  /** Input for creating a task. */
65
86
  export interface NewTaskInput {
66
87
  title: string;
67
88
  description: string;
68
89
  prompt: string;
90
+ /** Workspace the execution must run in; empty/absent = the recent workspace. */
91
+ workspaceId?: string;
92
+ /** Agent preset the execution session must be composed from; empty/absent = deployment default. */
93
+ mode?: string;
94
+ /** Permission preset applied to the execution session; absent = session default. */
95
+ permission?: TaskPermission;
69
96
  }
70
97
  /** The five kanban columns, in display order. */
71
98
  export declare const COLUMNS: readonly {
@@ -1,11 +1,16 @@
1
1
  /**
2
2
  * Update-task use case: apply an editable-field patch (title/description/
3
- * prompt) with a fresh updatedAt. Pure ledger transition (no persistence or
4
- * notify — the controller orchestrates those).
3
+ * prompt plus the execution targets workspaceId/mode/permission) with a
4
+ * fresh updatedAt. Pure ledger transition (no persistence or notify — the
5
+ * controller orchestrates those).
6
+ *
7
+ * An explicit `undefined` in the patch clears the field (the task falls
8
+ * back to the runtime default); an unknown permission string is ignored so
9
+ * stale UI can never persist a value the execution service rejects.
5
10
  */
6
- import type { TaskRecord } from '../tasks.ts';
11
+ import { type TaskRecord } from '../tasks.ts';
7
12
  /** Editable fields on a task (the update patch surface). */
8
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>;
13
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>;
9
14
  /**
10
15
  * Apply an update across the ledger. Tasks that do not match the id are left
11
16
  * untouched; the matched task receives the patch plus a fresh updatedAt.
@@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis';
14
14
  import z from 'schemastery';
15
15
  export declare const inject: string[];
16
16
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
17
- export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1B\u4EFB\u52A1\u53EF\u771F\u5B9E\u6267\u884C\uFF08\u9A71\u52A8 agent \u4F1A\u8BDD\uFF09\uFF1B\u4EFB\u52A1\u652F\u6301 5 \u6BB5 cron \u5B9A\u65F6\u6267\u884C\uFF08\u5982 0 23 * * *\uFF09\uFF1B\u6570\u636E\u5B58\u6D4F\u89C8\u5668 localStorage\uFF08\u952E dsh.taskBoard.v1\uFF09\u3002\u9650\u5236\uFF1A\u5B9A\u65F6\u8C03\u5EA6\u5728\u6D4F\u89C8\u5668\u7AEF\uFF0C\u9700 GUI \u6807\u7B7E\u9875\u6253\u5F00\uFF0C\u9519\u8FC7\u5373\u8DF3\u8FC7\uFF1B\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
17
+ export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1B\u4EFB\u52A1\u53EF\u771F\u5B9E\u6267\u884C\uFF08\u9A71\u52A8 agent \u4F1A\u8BDD\uFF09\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u6267\u884C\u76EE\u6807\u2014\u2014\u5DE5\u4F5C\u533A / \u6A21\u5F0F\uFF08agent \u9884\u8BBE\uFF09/ \u6743\u9650\uFF08read-only / workspace-write / danger-full-access\uFF09\uFF0C\u7F3A\u7701\u7528\u8FD0\u884C\u65F6\u9ED8\u8BA4\uFF1B\u4EFB\u52A1\u652F\u6301 5 \u6BB5 cron \u5B9A\u65F6\u6267\u884C\uFF08\u5982 0 23 * * *\uFF09\uFF1B\u6570\u636E\u5B58\u6D4F\u89C8\u5668 localStorage\uFF08\u952E dsh.taskBoard.v1\uFF09\u3002\u9650\u5236\uFF1A\u5B9A\u65F6\u8C03\u5EA6\u5728\u6D4F\u89C8\u5668\u7AEF\uFF0C\u9700 GUI \u6807\u7B7E\u9875\u6253\u5F00\uFF0C\u9519\u8FC7\u5373\u8DF3\u8FC7\uFF1B\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
18
18
  /**
19
19
  * Settings namespace of the board's announcement capability — the section the
20
20
  * web settings surface edits. Spelled here rather than imported: the browser
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Task board for the dsh web GUI: a sidebar entry plus a multi-column kanban view with local persistence, task details, and real execution through dsh sessions (session.prompt). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.15",
4
+ "version": "0.1.17",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -138,7 +138,13 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
138
138
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
139
139
  {props.children}
140
140
  <div className={css.footer}>
141
- {state.failed ? <p className={css.failed} role="status">{props.t('settings.saveFailed')}</p> : null}
141
+ {state.failed
142
+ ? (
143
+ <p className={css.failed} role="status">
144
+ {props.t('settings.saveFailed')}{state.failedReason ? ' - ' + state.failedReason : ''}
145
+ </p>
146
+ )
147
+ : null}
142
148
  <button
143
149
  type="button"
144
150
  className={css.discard}
@@ -2,9 +2,10 @@
2
2
  * New-task modal: title + description + the prompt that execution will send.
3
3
  * Creates through the controller (which persists immediately).
4
4
  */
5
- import { useState } from 'react'
5
+ import { useEffect, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
- import { t } from '../locales.ts'
7
+ import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
8
+ import { t, type TaskBoardKey } from '../locales.ts'
8
9
  import css from '../board.module.css'
9
10
 
10
11
  /** New-task form overlay. */
@@ -12,10 +13,28 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
12
13
  const [title, setTitle] = useState('')
13
14
  const [description, setDescription] = useState('')
14
15
  const [prompt, setPrompt] = useState('')
16
+ const [workspaceId, setWorkspaceId] = useState('')
17
+ const [mode, setMode] = useState('')
18
+ const [permission, setPermission] = useState('')
15
19
  const [error, setError] = useState<string | undefined>(undefined)
20
+ const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
21
+
22
+ // The workspace list and preset roster arrive from the runtime after mount;
23
+ // follow them so the pickers never freeze on an empty snapshot.
24
+ useEffect(
25
+ () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
26
+ [controller],
27
+ )
16
28
 
17
29
  const submit = (): void => {
18
- const task = controller.createTask({ title, description, prompt })
30
+ const task = controller.createTask({
31
+ title,
32
+ description,
33
+ prompt,
34
+ workspaceId: workspaceId === '' ? undefined : workspaceId,
35
+ mode: mode === '' ? undefined : mode,
36
+ permission: permission === '' ? undefined : permission as TaskPermission,
37
+ })
19
38
  if (task === undefined) {
20
39
  setError(t('new.required'))
21
40
  return
@@ -66,6 +85,52 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
66
85
  />
67
86
  </label>
68
87
 
88
+ <label className={css.field}>
89
+ <span className={css.fieldLabel}>{t('new.workspace')}</span>
90
+ <select
91
+ className={css.select}
92
+ value={workspaceId}
93
+ onChange={event => { setWorkspaceId(event.target.value) }}
94
+ >
95
+ <option value="">{t('exec.workspace.recent')}</option>
96
+ {options.workspaces.map(workspace => (
97
+ <option key={workspace.workspaceId} value={workspace.workspaceId}>{workspace.title}</option>
98
+ ))}
99
+ </select>
100
+ </label>
101
+
102
+ <label className={css.field}>
103
+ <span className={css.fieldLabel}>{t('new.mode')}</span>
104
+ <select
105
+ className={css.select}
106
+ value={mode}
107
+ onChange={event => { setMode(event.target.value) }}
108
+ >
109
+ <option value="">{t('exec.mode.default')}</option>
110
+ {options.presets.map(preset => (
111
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
112
+ {preset.name ?? preset.id}
113
+ {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
114
+ {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
115
+ </option>
116
+ ))}
117
+ </select>
118
+ </label>
119
+
120
+ <label className={css.field}>
121
+ <span className={css.fieldLabel}>{t('new.permission')}</span>
122
+ <select
123
+ className={css.select}
124
+ value={permission}
125
+ onChange={event => { setPermission(event.target.value) }}
126
+ >
127
+ <option value="">{t('exec.permission.default')}</option>
128
+ {TASK_PERMISSIONS.map(id => (
129
+ <option key={id} value={id}>{t(`exec.permission.${id}` as TaskBoardKey)}</option>
130
+ ))}
131
+ </select>
132
+ </label>
133
+
69
134
  {error !== undefined && <p className={css.formError}>{error}</p>}
70
135
 
71
136
  <footer className={css.modalFooter}>
@@ -7,7 +7,7 @@
7
7
  import { useEffect, useState } from 'react'
8
8
  import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
- import { MANUAL_STATUSES, type ExecutionRecord, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
10
+ import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
11
11
  import { t, type TaskBoardKey } from '../locales.ts'
12
12
  import css from '../board.module.css'
13
13
  import { ConfirmDialog } from './ConfirmDialog.tsx'
@@ -66,6 +66,74 @@ const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
66
66
  { cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
67
67
  ]
68
68
 
69
+ /** The execution-target editor: workspace / mode / permission pickers. */
70
+ function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
71
+ const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
72
+ useEffect(
73
+ () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
74
+ [controller],
75
+ )
76
+ const workspaceId = task.workspaceId ?? ''
77
+ const mode = task.mode ?? ''
78
+ const permission = task.permission ?? ''
79
+ // A pinned target may disappear from the runtime (workspace deleted,
80
+ // preset removed); keep it selectable as a stale row instead of silently
81
+ // dropping it, so the user sees exactly what the task will ask for.
82
+ const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
83
+ const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
84
+ return (
85
+ <section className={css.detailSection}>
86
+ <h4>{t('detail.executionSettings')}</h4>
87
+ <p className={css.detailText}>{t('exec.hint')}</p>
88
+ <label className={css.field}>
89
+ <span className={css.fieldLabel}>{t('new.workspace')}</span>
90
+ <select
91
+ className={css.select}
92
+ value={workspaceId}
93
+ onChange={event => { controller.updateTask(task.id, { workspaceId: event.target.value }) }}
94
+ >
95
+ <option value="">{t('exec.workspace.recent')}</option>
96
+ {!workspaceKnown && <option value={workspaceId}>{workspaceId}{t('exec.mode.removed')}</option>}
97
+ {options.workspaces.map(workspace => (
98
+ <option key={workspace.workspaceId} value={workspace.workspaceId}>{workspace.title}</option>
99
+ ))}
100
+ </select>
101
+ </label>
102
+ <label className={css.field}>
103
+ <span className={css.fieldLabel}>{t('new.mode')}</span>
104
+ <select
105
+ className={css.select}
106
+ value={mode}
107
+ onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
108
+ >
109
+ <option value="">{t('exec.mode.default')}</option>
110
+ {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
111
+ {options.presets.map(preset => (
112
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
113
+ {preset.name ?? preset.id}
114
+ {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
115
+ {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
116
+ </option>
117
+ ))}
118
+ </select>
119
+ </label>
120
+ <label className={css.field}>
121
+ <span className={css.fieldLabel}>{t('new.permission')}</span>
122
+ <select
123
+ className={css.select}
124
+ value={permission}
125
+ onChange={event => { controller.updateTask(task.id, { permission: event.target.value === '' ? undefined : event.target.value as TaskPermission }) }}
126
+ >
127
+ <option value="">{t('exec.permission.default')}</option>
128
+ {TASK_PERMISSIONS.map(id => (
129
+ <option key={id} value={id}>{t(`exec.permission.${id}` as TaskBoardKey)}</option>
130
+ ))}
131
+ </select>
132
+ </label>
133
+ </section>
134
+ )
135
+ }
136
+
69
137
  /** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
70
138
  function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
71
139
  const schedule = task.schedule
@@ -203,6 +271,8 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
203
271
  <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
204
272
  </section>
205
273
 
274
+ <ExecutionSettingsSection controller={controller} task={current} />
275
+
206
276
  <ScheduleSection controller={controller} task={current} />
207
277
 
208
278
  <section className={css.detailSection}>
@@ -512,6 +512,18 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
512
512
  border-color: var(--dsw-alias-state-business-primary);
513
513
  }
514
514
 
515
+ .select {
516
+ padding: 7px 10px;
517
+ font-size: 13px;
518
+ color: var(--dsw-alias-label-primary);
519
+ background: var(--dsw-specific-input-major);
520
+ border: 1px solid var(--dsw-alias-border-l2);
521
+ border-radius: 8px;
522
+ outline: none;
523
+ font-family: inherit;
524
+ max-width: 100%;
525
+ }
526
+
515
527
  .input::placeholder {
516
528
  color: var(--dsw-alias-label-tertiary);
517
529
  }
@@ -775,6 +787,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
775
787
  .linkButton:focus-visible,
776
788
  .search:focus-visible,
777
789
  .input:focus-visible,
790
+ .select:focus-visible,
778
791
  .schedulePreset:focus-visible,
779
792
  .scheduleToggle input:focus-visible {
780
793
  outline: 2px solid var(--dsw-alias-state-business-primary);
@@ -790,6 +803,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
790
803
  .linkButton,
791
804
  .search,
792
805
  .input,
806
+ .select,
793
807
  .schedulePreset,
794
808
  .scheduleToggle input {
795
809
  transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, outline-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
@@ -834,6 +848,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
834
848
 
835
849
  /* --- search box focus (border tint + themed ring above) --- */
836
850
  .search:focus,
851
+ .select:focus,
837
852
  .schedulePreset:focus {
838
853
  border-color: var(--dsw-alias-state-business-primary);
839
854
  }
@@ -855,6 +870,7 @@ html[data-dsh-taskboard-active]:not([data-dsh-ssh-active]) [class*='centerCol']
855
870
  .linkButton,
856
871
  .search,
857
872
  .input,
873
+ .select,
858
874
  .schedulePreset,
859
875
  .scheduleToggle input {
860
876
  transition: none;