@linxin666/dsh-client-ui-task-board 0.3.3 → 0.3.5

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,6 +5,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
5
5
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, win32 } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
8
9
  //#region src/core/schedule.ts
9
10
  /** Inclusive ranges per field, in cron order. */
10
11
  const FIELD_RANGES = [
@@ -182,8 +183,9 @@ function isDigits(value) {
182
183
  */
183
184
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
184
185
  function expandHome(path, home = homedir()) {
186
+ const j = home.startsWith("/") ? join$1 : join;
185
187
  if (path === "~") return home;
186
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
188
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
187
189
  return path;
188
190
  }
189
191
  /**
@@ -193,12 +195,15 @@ function expandHome(path, home = homedir()) {
193
195
  * @returns the absolute DSH home path.
194
196
  */
195
197
  function resolveDshHome(env = process.env, home = homedir()) {
198
+ const isPosix = home.startsWith("/");
199
+ const j = isPosix ? join$1 : join;
200
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
196
201
  const raw = env.DSH_HOME;
197
202
  if (raw !== void 0 && raw.trim() !== "") {
198
203
  const expanded = expandHome(raw.trim(), home);
199
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
204
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
200
205
  }
201
- return join(home, ".dsh");
206
+ return j(home, ".dsh");
202
207
  }
203
208
  /** Resolve the DSH home directory from the live environment. */
204
209
  function dshHome() {
@@ -611,10 +616,34 @@ function applyScheduleNextRun(tasks, id, nextRunAt, lastTriggeredAt, now) {
611
616
  * fresh updatedAt. Pure ledger transition (no persistence or notify — the
612
617
  * controller orchestrates those).
613
618
  *
614
- * An explicit `undefined` in the patch clears the field (the task falls
615
- * back to the runtime default); an unknown permission string is ignored so
616
- * stale UI can never persist a value the execution service rejects.
619
+ * An explicit `undefined` in the patch clears an execution-target field
620
+ * (the task falls back to the runtime default); content fields are trimmed
621
+ * and never cleared. An unknown permission string is ignored so stale UI
622
+ * can never persist a value the execution service rejects.
617
623
  */
624
+ /**
625
+ * The fields that edit the task's content (what the user reads and what the
626
+ * next execution sends). Unlike the execution targets they stay editable only
627
+ * while the task has never started executing — after the first run the
628
+ * recorded prompt is the record of what actually ran, so it becomes read-only.
629
+ */
630
+ const TASK_CONTENT_FIELDS = [
631
+ "title",
632
+ "description",
633
+ "prompt"
634
+ ];
635
+ /** Whether an update patch touches any task-content field. */
636
+ function hasContentPatch(patch) {
637
+ return TASK_CONTENT_FIELDS.some((field) => field in patch);
638
+ }
639
+ /**
640
+ * Whether a task's content may still be edited: the task must be on-board
641
+ * (not archived) and must never have started executing. Fail-closed: a
642
+ * running, settled, or cancelled-before-launch task keeps its content fixed.
643
+ */
644
+ function canEditTaskContent(task) {
645
+ return task.archivedAt === void 0 && task.status !== "running" && task.executions.length === 0;
646
+ }
618
647
  /** Keep an unknown permission string from entering the ledger. */
619
648
  function normalizePermission(current, value) {
620
649
  if (value === void 0) return void 0;
@@ -639,6 +668,11 @@ function applyUpdateTask(tasks, id, patch, now) {
639
668
  ...patch,
640
669
  updatedAt: now
641
670
  };
671
+ for (const field of TASK_CONTENT_FIELDS) {
672
+ if (!(field in patch)) continue;
673
+ const value = patch[field];
674
+ next[field] = value === void 0 ? task[field] : value.trim();
675
+ }
642
676
  if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
643
677
  if (mode !== void 0 || "mode" in patch) next.mode = mode;
644
678
  if (permission !== void 0 || "permission" in patch) next.permission = permission;
@@ -1266,6 +1300,8 @@ var HostTaskLedger = class {
1266
1300
  const task = this.document.tasks.find((task) => task.id === action.taskId);
1267
1301
  if (task === void 0) throw new Error("task not found");
1268
1302
  if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
1303
+ if (hasContentPatch(action.patch) && !canEditTaskContent(task)) throw new Error("task has already been executed");
1304
+ if ("title" in action.patch && action.patch.title?.trim() === "") throw new Error("title is required");
1269
1305
  this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)];
1270
1306
  break;
1271
1307
  }
@@ -22,6 +22,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
22
22
  titleKey: TKey;
23
23
  /** Locale key of the line describing what this plugin's settings govern. */
24
24
  descriptionKey: TKey;
25
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
26
+ descriptionNode?: ReactNode;
25
27
  /** The card's form state: availability, writability, and what a save would do. */
26
28
  state: CardShell;
27
29
  /** Write every staged edit. */
@@ -0,0 +1,8 @@
1
+ import type { BoardController } from '../../core/controller.ts';
2
+ import type { TaskRecord } from '../../core/tasks.ts';
3
+ /** Edit-task form overlay. */
4
+ export declare function EditTaskModal({ controller, task, onClose }: {
5
+ controller: BoardController;
6
+ task: TaskRecord;
7
+ onClose: () => void;
8
+ }): import("react").JSX.Element;
@@ -2,7 +2,6 @@ import type { TaskRecord } from '../../core/tasks.ts';
2
2
  /** Compact relative/absolute time label. */
3
3
  export declare function formatHostTimestamp(ms: number, timeZone?: string): string;
4
4
  export declare function formatTime(ms: number, timeZone?: string): string;
5
- /** One card in a column. */
6
5
  declare function TaskCardInner({ task, pending, timeZone, onClick }: {
7
6
  task: TaskRecord;
8
7
  pending: boolean;
@@ -40,7 +40,10 @@ export declare const zh: {
40
40
  'new.cancel': string;
41
41
  'new.required': string;
42
42
  'detail.title': string;
43
+ 'detail.edit': string;
43
44
  'detail.close': string;
45
+ 'edit.title': string;
46
+ 'edit.save': string;
44
47
  'detail.prompt': string;
45
48
  'detail.description': string;
46
49
  'detail.execution': string;
@@ -2,8 +2,8 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store seam remains for v1 migration tests. It also closes the
6
- * board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. The board closes
6
+ * only on explicit user navigation, never implicitly on session-list churn.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
@@ -125,7 +125,15 @@ export declare class BoardController {
125
125
  createTask(input: NewTaskInput): TaskRecord | undefined;
126
126
  /** Create through the Host and expose the task only after confirmation. */
127
127
  createTaskConfirmed(input: NewTaskInput): Promise<TaskRecord | undefined>;
128
- updateTask(id: string, patch: TaskUpdatePatch): void;
128
+ /**
129
+ * Apply an editable-field patch (task content + execution targets).
130
+ * Host-backed: the Host ledger owns the fail-closed checks (the content of
131
+ * an executed task is read-only) and confirms the mutation, so the resolved
132
+ * value reflects whether the Host accepted it; the legacy in-memory path
133
+ * applies and persists synchronously.
134
+ * @returns true when the patch was accepted by the authority.
135
+ */
136
+ updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean>;
129
137
  /**
130
138
  * Replace (a part of) the picker option sets the UI feeds (workspace list
131
139
  * and agent-preset roster come from the runtime, not the ledger).
@@ -180,9 +188,17 @@ export declare class BoardController {
180
188
  runTask(id: string): Promise<boolean>;
181
189
  /** Re-run a settled task through the Host (the Host replans and executes). */
182
190
  rerunTask(id: string): Promise<void>;
183
- /** Close the board when the user navigates to another session. */
191
+ /**
192
+ * Session-list notifications fire for all kinds of incidental churn
193
+ * (background navigation, the Host runner creating and selecting a fresh
194
+ * execution session, settlement, other plugins), so closing on `current`
195
+ * changes would evict the board without the user asking. The board closes
196
+ * only on explicit user navigation: a sidebar session/workspace row click
197
+ * (board-mount onClickSidebarRow) or the board's own actions
198
+ * (openSession / close). Keeping the hook preserves the subscription
199
+ * contract for future listeners.
200
+ */
184
201
  private onSessionsChanged;
185
- private lastCurrent;
186
202
  private persistAndNotify;
187
203
  private commitRemote;
188
204
  private performRemote;
@@ -4,13 +4,29 @@
4
4
  * fresh updatedAt. Pure ledger transition (no persistence or notify — the
5
5
  * controller orchestrates those).
6
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.
7
+ * An explicit `undefined` in the patch clears an execution-target field
8
+ * (the task falls back to the runtime default); content fields are trimmed
9
+ * and never cleared. An unknown permission string is ignored so stale UI
10
+ * can never persist a value the execution service rejects.
10
11
  */
11
12
  import { type TaskRecord } from '../tasks.ts';
12
13
  /** Editable fields on a task (the update patch surface). */
13
14
  export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>;
15
+ /**
16
+ * The fields that edit the task's content (what the user reads and what the
17
+ * next execution sends). Unlike the execution targets they stay editable only
18
+ * while the task has never started executing — after the first run the
19
+ * recorded prompt is the record of what actually ran, so it becomes read-only.
20
+ */
21
+ export declare const TASK_CONTENT_FIELDS: readonly ["title", "description", "prompt"];
22
+ /** Whether an update patch touches any task-content field. */
23
+ export declare function hasContentPatch(patch: TaskUpdatePatch): boolean;
24
+ /**
25
+ * Whether a task's content may still be edited: the task must be on-board
26
+ * (not archived) and must never have started executing. Fail-closed: a
27
+ * running, settled, or cancelled-before-launch task keeps its content fixed.
28
+ */
29
+ export declare function canEditTaskContent(task: TaskRecord): boolean;
14
30
  /**
15
31
  * Apply an update across the ledger. Tasks that do not match the id are left
16
32
  * untouched; the matched task receives the patch plus a fresh updatedAt.
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.3",
4
+ "version": "0.3.5",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
39
39
  titleKey: TKey
40
40
  /** Locale key of the line describing what this plugin's settings govern. */
41
41
  descriptionKey: TKey
42
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
43
+ descriptionNode?: ReactNode
42
44
  /** The card's form state: availability, writability, and what a save would do. */
43
45
  state: CardShell
44
46
  /** Write every staged edit. */
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
87
89
  <div className={css.headerStatic}>
88
90
  <span className={css.headText}>
89
91
  <span className={css.name} title={title}>{title}</span>
90
- <span className={css.description} title={description}>{description}</span>
92
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
91
93
  </span>
92
94
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
93
95
  </div>
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
102
104
  >
103
105
  <span className={css.headText}>
104
106
  <span className={css.name} title={title}>{title}</span>
105
- <span className={css.description} title={description}>{description}</span>
107
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
106
108
  </span>
107
109
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
108
110
  <svg
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Edit-task modal: title + description + the prompt the next execution will
3
+ * send, pre-filled from the task. Shown only for tasks that have never
4
+ * started executing (the detail view gates on canEditTaskContent); the Host
5
+ * still re-checks at submit, so a task that started running while the modal
6
+ * was open fails closed and the error surfaces here.
7
+ */
8
+ import { useState } from 'react'
9
+ import type { BoardController } from '../../core/controller.ts'
10
+ import type { TaskRecord } from '../../core/tasks.ts'
11
+ import { t } from '../locales.ts'
12
+ import css from '../board.module.css'
13
+
14
+ /** Edit-task form overlay. */
15
+ export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
16
+ const [title, setTitle] = useState(task.title)
17
+ const [description, setDescription] = useState(task.description)
18
+ const [prompt, setPrompt] = useState(task.prompt)
19
+ const [error, setError] = useState<string | undefined>(undefined)
20
+ const [pending, setPending] = useState(false)
21
+
22
+ const submit = async (): Promise<void> => {
23
+ if (title.trim() === '') {
24
+ setError(t('new.required'))
25
+ return
26
+ }
27
+ setPending(true)
28
+ // The Host confirms the mutation (and its fail-closed checks); only a
29
+ // confirmed save closes the modal.
30
+ if (await controller.updateTask(task.id, { title, description, prompt })) {
31
+ onClose()
32
+ return
33
+ }
34
+ setPending(false)
35
+ setError(controller.getSnapshot().transportError ?? t('new.required'))
36
+ }
37
+
38
+ return (
39
+ <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
40
+ <form
41
+ className={css.modal}
42
+ role="dialog"
43
+ aria-label={t('edit.title')}
44
+ onSubmit={event => { event.preventDefault(); void submit() }}
45
+ >
46
+ <h2 className={css.modalTitle}>{t('edit.title')}</h2>
47
+
48
+ <label className={css.field}>
49
+ <span className={css.fieldLabel}>{t('new.title')}</span>
50
+ <input
51
+ className={css.input}
52
+ value={title}
53
+ autoFocus
54
+ placeholder={t('new.titlePlaceholder')}
55
+ onChange={event => { setTitle(event.target.value); setError(undefined) }}
56
+ />
57
+ </label>
58
+
59
+ <label className={css.field}>
60
+ <span className={css.fieldLabel}>{t('new.description')}</span>
61
+ <textarea
62
+ className={css.input}
63
+ rows={3}
64
+ value={description}
65
+ placeholder={t('new.descriptionPlaceholder')}
66
+ onChange={event => { setDescription(event.target.value) }}
67
+ />
68
+ </label>
69
+
70
+ <label className={css.field}>
71
+ <span className={css.fieldLabel}>{t('new.prompt')}</span>
72
+ <textarea
73
+ className={css.input}
74
+ rows={4}
75
+ value={prompt}
76
+ placeholder={t('new.promptPlaceholder')}
77
+ onChange={event => { setPrompt(event.target.value) }}
78
+ />
79
+ </label>
80
+
81
+ {error !== undefined && <p className={css.formError}>{error}</p>}
82
+
83
+ <footer className={css.modalFooter}>
84
+ <button type="button" className={css.ghostButton} onClick={onClose}>
85
+ {t('new.cancel')}
86
+ </button>
87
+ <button type="submit" className={css.primaryButton} disabled={pending}>
88
+ {t('edit.save')}
89
+ </button>
90
+ </footer>
91
+ </form>
92
+ </div>
93
+ )
94
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { memo, useCallback, useEffect, useState } from 'react'
7
7
  import { selectedTaskOf, type BoardController } from '../../core/controller.ts'
8
- import { COLUMNS, type TaskRecord } from '../../core/tasks.ts'
8
+ import { COLUMNS, canMoveManually, type TaskRecord } from '../../core/tasks.ts'
9
9
  import { t } from '../locales.ts'
10
10
  import css from '../board.module.css'
11
11
  import { NewTaskModal } from './NewTaskModal.tsx'
@@ -124,8 +124,27 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
124
124
  ) : (
125
125
  COLUMNS.map(column => {
126
126
  const tasks = visible.filter(task => task.status === column.status)
127
+ const isManualDropTarget = column.status === 'backlog' || column.status === 'todo'
127
128
  return (
128
- <section key={column.status} className={css.column} data-status={column.status} data-dsh-part="column">
129
+ <section
130
+ key={column.status}
131
+ className={css.column}
132
+ data-status={column.status}
133
+ data-dsh-part="column"
134
+ onDragOver={isManualDropTarget ? (event) => {
135
+ event.preventDefault()
136
+ event.dataTransfer.dropEffect = 'move'
137
+ } : undefined}
138
+ onDrop={isManualDropTarget ? (event) => {
139
+ event.preventDefault()
140
+ const taskId = event.dataTransfer.getData('text/plain')
141
+ if (!taskId) return
142
+ const dropped = snapshot.tasks.find(t => t.id === taskId)
143
+ if (dropped && canMoveManually(dropped.status, column.status) && dropped.status !== column.status) {
144
+ controller.moveTask(taskId, column.status)
145
+ }
146
+ } : undefined}
147
+ >
129
148
  <header className={css.columnHeader}>
130
149
  <span className={css.statusDot} data-status={column.status} aria-hidden="true" />
131
150
  <h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
@@ -37,11 +37,12 @@ export function formatTime(ms: number, timeZone?: string): string {
37
37
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
38
38
  }
39
39
 
40
- /** One card in a column. */
41
40
  function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord; pending: boolean; timeZone?: string; onClick: () => void }) {
42
41
  const latest = task.executions[task.executions.length - 1]
43
42
  const runs = task.executions.length
44
43
  const archived = task.archivedAt !== undefined
44
+ const isDraggable = !archived && task.status !== 'running' && !pending
45
+
45
46
  return (
46
47
  <button
47
48
  type="button"
@@ -49,6 +50,11 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
49
50
  data-status={archived ? 'archived' : task.status}
50
51
  data-dsh-part="card"
51
52
  data-pending={pending || undefined}
53
+ draggable={isDraggable}
54
+ onDragStart={isDraggable ? (event) => {
55
+ event.dataTransfer.setData('text/plain', task.id)
56
+ event.dataTransfer.effectAllowed = 'move'
57
+ } : undefined}
52
58
  onClick={onClick}
53
59
  title={task.description !== '' ? task.description : task.title}
54
60
  >
@@ -8,10 +8,12 @@ import { useEffect, useState } from 'react'
8
8
  import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
10
  import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
+ import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
11
12
  import { t, type TaskBoardKey } from '../locales.ts'
12
13
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
14
  import css from '../board.module.css'
14
15
  import { ConfirmDialog } from './ConfirmDialog.tsx'
16
+ import { EditTaskModal } from './EditTaskModal.tsx'
15
17
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
16
18
  import { STATUS_KEY } from './status-key.ts'
17
19
 
@@ -232,10 +234,13 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
232
234
  /** Task detail overlay. */
233
235
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
234
236
  const [confirmDelete, setConfirmDelete] = useState(false)
237
+ const [showEdit, setShowEdit] = useState(false)
235
238
 
236
239
  // Keep the overlay in sync if the task record changes underneath.
237
240
  const [latest, setLatest] = useState(task)
238
241
  useEffect(() => { setLatest(task) }, [task])
242
+ // A re-used overlay instance must not carry an edit session across tasks.
243
+ useEffect(() => { setShowEdit(false) }, [task.id])
239
244
  const current = latest
240
245
  const snapshot = controller.getSnapshot()
241
246
  const running = current.status === 'running'
@@ -328,6 +333,16 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
328
333
 
329
334
  <footer className={css.detailFooter}>
330
335
  {!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
336
+ {!archived && canEditTaskContent(current) && (
337
+ <button
338
+ type="button"
339
+ className={css.ghostButton}
340
+ disabled={pending}
341
+ onClick={() => { setShowEdit(true) }}
342
+ >
343
+ {t('detail.edit')}
344
+ </button>
345
+ )}
331
346
  {!archived && (
332
347
  <button
333
348
  type="button"
@@ -395,6 +410,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
395
410
  }}
396
411
  />
397
412
  )}
413
+
414
+ {showEdit && !archived && canEditTaskContent(current) && (
415
+ <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
416
+ )}
398
417
  </div>
399
418
  )
400
419
  }
@@ -41,7 +41,10 @@ export const zh = {
41
41
  'new.cancel': '取消',
42
42
  'new.required': '标题不能为空',
43
43
  'detail.title': '任务详情',
44
+ 'detail.edit': '编辑',
44
45
  'detail.close': '关闭',
46
+ 'edit.title': '编辑任务',
47
+ 'edit.save': '保存',
45
48
  'detail.prompt': '执行 Prompt',
46
49
  'detail.description': '描述',
47
50
  'detail.execution': '执行记录',
@@ -164,7 +167,10 @@ export const en: Record<keyof typeof zh, string> = {
164
167
  'new.cancel': 'Cancel',
165
168
  'new.required': 'Title is required',
166
169
  'detail.title': 'Task Detail',
170
+ 'detail.edit': 'Edit',
167
171
  'detail.close': 'Close',
172
+ 'edit.title': 'Edit Task',
173
+ 'edit.save': 'Save',
168
174
  'detail.prompt': 'Run Prompt',
169
175
  'detail.description': 'Description',
170
176
  'detail.execution': 'Execution History',
@@ -24,6 +24,10 @@
24
24
  .header {
25
25
  appearance: none;
26
26
  width: 100%;
27
+ /* The suite does not force a global border-box, so width:100% + padding
28
+ would stretch the row 32px past the card and push the trailing pending
29
+ badge (last flex item) outside the card border. */
30
+ box-sizing: border-box;
27
31
  font: inherit;
28
32
  color: inherit;
29
33
  text-align: left;
@@ -44,6 +48,7 @@
44
48
 
45
49
  .headerStatic {
46
50
  width: 100%;
51
+ box-sizing: border-box;
47
52
  border-radius: 12px;
48
53
  align-items: center;
49
54
  gap: 12px;
@@ -2,8 +2,8 @@
2
2
  * Board controller: the single owner of task-ledger state and view state.
3
3
  *
4
4
  * In production it projects the Host ledger and submits confirmed actions;
5
- * the legacy store seam remains for v1 migration tests. It also closes the
6
- * board whenever the user navigates to a session.
5
+ * the legacy store seam remains for v1 migration tests. The board closes
6
+ * only on explicit user navigation, never implicitly on session-list churn.
7
7
  * Framework-free (structural runtime faces) so the whole orchestration is
8
8
  * unit-testable with fakes.
9
9
  *
@@ -108,11 +108,6 @@ function randomUuid(): string {
108
108
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
109
109
  }
110
110
 
111
- /** Read the current selection off a session-list snapshot (structural). */
112
- function currentOf(sessions: SessionsControllerFace): string | undefined {
113
- return sessions.list.getSnapshot().current
114
- }
115
-
116
111
  function messageOf(error: unknown): string {
117
112
  return error instanceof Error ? error.message : String(error)
118
113
  }
@@ -205,10 +200,11 @@ export class BoardController {
205
200
 
206
201
  openBoard(): void {
207
202
  if (this.boardOpen) return
208
- // Baseline the selection the board opened against: the board stays open
209
- // until the user navigates (selection changes), never on mere status
210
- // updates of the already-selected session.
211
- this.lastCurrent = currentOf(this.deps.sessions)
203
+ // The board stays open until the user explicitly closes it: by clicking
204
+ // the sidebar entry again, a sidebar session/workspace row, or a board
205
+ // action (openSession / close). Session-list churn (background
206
+ // navigation, the Host runner selecting a fresh execution session,
207
+ // settlement, other plugins) never evicts the board.
212
208
  this.boardOpen = true
213
209
  this.notify()
214
210
  }
@@ -273,13 +269,21 @@ export class BoardController {
273
269
  : undefined
274
270
  }
275
271
 
276
- updateTask(id: string, patch: TaskUpdatePatch): void {
272
+ /**
273
+ * Apply an editable-field patch (task content + execution targets).
274
+ * Host-backed: the Host ledger owns the fail-closed checks (the content of
275
+ * an executed task is read-only) and confirms the mutation, so the resolved
276
+ * value reflects whether the Host accepted it; the legacy in-memory path
277
+ * applies and persists synchronously.
278
+ * @returns true when the patch was accepted by the authority.
279
+ */
280
+ async updateTask(id: string, patch: TaskUpdatePatch): Promise<boolean> {
277
281
  if (this.deps.transport !== undefined) {
278
- void this.commitRemote({ kind: 'update', taskId: id, patch }, id)
279
- return
282
+ return await this.commitRemote({ kind: 'update', taskId: id, patch }, id)
280
283
  }
281
284
  this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
282
285
  this.persistAndNotify()
286
+ return true
283
287
  }
284
288
 
285
289
  /**
@@ -420,16 +424,20 @@ export class BoardController {
420
424
 
421
425
  // --- internals ---------------------------------------------------------------
422
426
 
423
- /** Close the board when the user navigates to another session. */
427
+ /**
428
+ * Session-list notifications fire for all kinds of incidental churn
429
+ * (background navigation, the Host runner creating and selecting a fresh
430
+ * execution session, settlement, other plugins), so closing on `current`
431
+ * changes would evict the board without the user asking. The board closes
432
+ * only on explicit user navigation: a sidebar session/workspace row click
433
+ * (board-mount onClickSidebarRow) or the board's own actions
434
+ * (openSession / close). Keeping the hook preserves the subscription
435
+ * contract for future listeners.
436
+ */
424
437
  private onSessionsChanged(): void {
425
- if (!this.boardOpen) return
426
- const current = currentOf(this.deps.sessions)
427
- if (current !== this.lastCurrent) this.closeBoard()
428
- this.lastCurrent = current
438
+ // Intentionally empty: never close the board implicitly.
429
439
  }
430
440
 
431
- private lastCurrent: string | undefined = undefined
432
-
433
441
  private persistAndNotify(): void {
434
442
  if (this.deps.transport === undefined) this.deps.store.save(this.tasks)
435
443
  this.notify()