@hyperdreamer/pi-webui 1.14.1 → 1.15.1

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.
@@ -1,48 +1,115 @@
1
1
  // Generated from pi-webui-plugins/workspace-tasks/tasksPanelElement.ts. Do not edit directly.
2
- import { TASKS_CONFIG_PATH } from "./config.js";
2
+ import { TASKS_CONFIG_PATH, appendWorkspaceTask, emptyWorkspaceTasksConfig, removeWorkspaceTaskAt, replaceWorkspaceTaskAt, suggestWorkspaceTaskId, validateAndNormalizeDraft, } from "./config.js";
3
3
  import { runWorkspaceTaskInTerminal } from "./taskRunner.js";
4
- import { loadWorkspaceTasksConfig, tasksConfigRefreshHint, tasksConfigUnavailableMessage } from "./workspaceTasksClient.js";
4
+ import { ensureWorkspaceTasksConfig, getWorkspaceTasksCacheEntry, guardedWriteWorkspaceTasksConfig, refreshWorkspaceTasksConfig, subscribeWorkspaceTasksConfig, } from "./workspaceTasksClient.js";
5
5
  export const tasksPanelTagName = "pi-webui-workspace-tasks-panel";
6
- const configChangedEvent = "pi-webui-workspace-tasks-config-changed";
7
- const configCache = new Map();
8
6
  export function defineTasksPanelElement() {
9
7
  if (!customElements.get(tasksPanelTagName))
10
8
  customElements.define(tasksPanelTagName, PiWebUiTasksPanel);
11
9
  }
12
10
  export function tasksPanelBadge(context) {
13
- const state = getCachedWorkspaceConfig(context);
14
- return state?.kind === "unavailable" ? "!" : undefined;
11
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
12
+ return entry !== undefined && (entry.state.kind === "unavailable" || entry.refreshRequired) ? "!" : undefined;
15
13
  }
16
14
  class PiWebUiTasksPanel extends HTMLElement {
17
15
  contextValue;
18
16
  runningTaskId;
19
17
  status;
18
+ mode = "view";
19
+ operation;
20
+ editor;
21
+ deleteState;
22
+ resetState;
23
+ failure;
24
+ validationErrors;
25
+ idManuallyEdited = false;
26
+ pendingRefreshFocus;
27
+ operationGeneration = 0;
28
+ terminalRunGeneration = 0;
29
+ panelRefreshRequired = false;
30
+ selectionGeneration = 0;
31
+ connected = false;
32
+ unsubscribe;
20
33
  root;
21
- onConfigChanged = () => {
34
+ onConfigChanged = (workspaceKey) => {
35
+ const context = this.contextValue;
36
+ if (!this.connected || context === undefined || cacheKeyForContext(context) !== workspaceKey)
37
+ return;
38
+ context.host.requestRender();
22
39
  this.render();
23
40
  };
41
+ onKeyDown = (event) => {
42
+ if (event.key !== "Escape" || this.operation !== undefined)
43
+ return;
44
+ if (this.mode === "add" || this.mode === "edit" || (this.mode === "conflicted" && this.failure?.action === "editor") || (this.mode === "needs-refresh-after-write" && this.failure?.action === "editor")) {
45
+ event.preventDefault();
46
+ event.stopPropagation();
47
+ this.cancelEditor();
48
+ return;
49
+ }
50
+ if (this.mode === "delete-confirm" || (this.mode === "conflicted" && this.failure?.action === "delete") || (this.mode === "needs-refresh-after-write" && this.failure?.action === "delete")) {
51
+ event.preventDefault();
52
+ event.stopPropagation();
53
+ this.cancelDelete();
54
+ return;
55
+ }
56
+ if (this.mode === "reset-confirm" || (this.mode === "conflicted" && this.failure?.action === "reset") || (this.mode === "needs-refresh-after-write" && this.failure?.action === "reset")) {
57
+ event.preventDefault();
58
+ event.stopPropagation();
59
+ this.cancelReset();
60
+ return;
61
+ }
62
+ if (this.mode === "refresh-discard-confirm") {
63
+ event.preventDefault();
64
+ event.stopPropagation();
65
+ this.cancelRefreshDiscard();
66
+ }
67
+ };
24
68
  constructor() {
25
69
  super();
26
70
  this.root = this.attachShadow({ mode: "open" });
71
+ this.root.addEventListener("keydown", (event) => {
72
+ if (event instanceof KeyboardEvent)
73
+ this.onKeyDown(event);
74
+ });
27
75
  }
28
76
  set context(value) {
29
77
  const previousKey = this.contextValue === undefined ? undefined : cacheKeyForContext(this.contextValue);
30
78
  const nextKey = value === undefined ? undefined : cacheKeyForContext(value);
31
79
  this.contextValue = value;
32
- // Parent app updates should not rebuild this shadow DOM for the same workspace:
33
- // doing so resets the mobile scroll position and can replace buttons mid-click.
34
80
  if (previousKey === nextKey)
35
81
  return;
82
+ this.selectionGeneration += 1;
83
+ this.operationGeneration += 1;
84
+ this.terminalRunGeneration += 1;
85
+ this.operation = undefined;
36
86
  this.runningTaskId = undefined;
87
+ this.mode = "view";
88
+ this.editor = undefined;
89
+ this.deleteState = undefined;
90
+ this.resetState = undefined;
91
+ this.failure = undefined;
92
+ this.validationErrors = undefined;
93
+ this.pendingRefreshFocus = undefined;
94
+ this.idManuallyEdited = false;
95
+ this.panelRefreshRequired = false;
37
96
  this.status = undefined;
38
97
  this.render();
39
98
  }
40
99
  connectedCallback() {
41
- window.addEventListener(configChangedEvent, this.onConfigChanged);
100
+ this.connected = true;
101
+ this.unsubscribe = subscribeWorkspaceTasksConfig(this.onConfigChanged);
42
102
  this.render();
43
103
  }
44
104
  disconnectedCallback() {
45
- window.removeEventListener(configChangedEvent, this.onConfigChanged);
105
+ this.connected = false;
106
+ this.selectionGeneration += 1;
107
+ this.operationGeneration += 1;
108
+ this.terminalRunGeneration += 1;
109
+ this.operation = undefined;
110
+ this.runningTaskId = undefined;
111
+ this.unsubscribe?.();
112
+ this.unsubscribe = undefined;
46
113
  }
47
114
  render() {
48
115
  const context = this.contextValue;
@@ -50,118 +117,707 @@ class PiWebUiTasksPanel extends HTMLElement {
50
117
  this.root.innerHTML = `${taskStyles()}<section class="empty">Select a workspace.</section>`;
51
118
  return;
52
119
  }
53
- const state = getOrLoadWorkspaceConfig(context);
120
+ const entry = ensureWorkspaceTasksConfig(context.files, cacheKeyForContext(context));
121
+ const refreshDisabled = this.operation !== undefined || this.isConfirmationMode();
122
+ const showAdd = this.mode === "view"
123
+ && !this.isRefreshRequired(entry)
124
+ && (entry.state.kind === "loaded" || entry.state.kind === "missing");
125
+ const showReset = this.mode === "view" && !this.isRefreshRequired(entry) && entry.state.kind === "invalid";
54
126
  this.root.innerHTML = `
55
127
  ${taskStyles()}
56
- <section class="toolbar">
57
- <strong>Workspace Tasks</strong>
58
- <span class="toolbar-tasks">
59
- <button class="secondary" data-refresh-config ${state.kind === "loading" ? "disabled" : ""}>Refresh</button>
60
- <button class="secondary" data-open-terminal>Open Terminal</button>
61
- </span>
128
+ <main class="tasks-panel" data-panel-mode="${escapeAttr(this.mode)}">
129
+ <section class="toolbar">
130
+ <h2 data-panel-heading tabindex="-1">Workspace Tasks</h2>
131
+ <span class="toolbar-tasks">
132
+ ${showAdd ? `<button type="button" class="secondary" data-add-task>Add Task</button>` : ""}
133
+ <button type="button" class="secondary" data-refresh-config ${refreshDisabled ? "disabled" : ""}>Refresh</button>
134
+ <button type="button" class="secondary" data-open-terminal>Open Terminal</button>
135
+ </span>
136
+ </section>
137
+ ${this.renderStatus()}
138
+ <section class="viewer tasks-viewer">
139
+ ${this.renderBody(entry, showReset)}
140
+ </section>
141
+ </main>
142
+ `;
143
+ this.bindHandlers(context);
144
+ }
145
+ isRefreshRequired(entry) {
146
+ return this.panelRefreshRequired || entry.refreshRequired;
147
+ }
148
+ isConfirmationMode() {
149
+ return this.mode === "delete-confirm" || this.mode === "reset-confirm" || this.mode === "refresh-discard-confirm";
150
+ }
151
+ renderBody(entry, showReset) {
152
+ if (this.mode === "add" || this.mode === "edit")
153
+ return this.renderEditor(entry, false);
154
+ if (this.mode === "delete-confirm")
155
+ return this.renderDeleteConfirmation(false);
156
+ if (this.mode === "reset-confirm")
157
+ return this.renderResetConfirmation(false);
158
+ if (this.mode === "refresh-discard-confirm")
159
+ return this.renderRefreshDiscardConfirmation();
160
+ if (this.mode === "conflicted" || this.mode === "needs-refresh-after-write") {
161
+ const action = this.failure?.action;
162
+ if (action === "editor")
163
+ return this.renderEditor(entry, true);
164
+ if (action === "delete")
165
+ return this.renderDeleteConfirmation(true);
166
+ if (action === "reset")
167
+ return this.renderResetConfirmation(true);
168
+ }
169
+ return this.renderConfigState(entry, showReset);
170
+ }
171
+ renderConfigState(entry, showReset) {
172
+ const state = entry.state;
173
+ if (state.kind === "loading")
174
+ return `<p class="muted" data-loading>Loading ${escapeHtml(TASKS_CONFIG_PATH)}...</p>`;
175
+ if (state.kind === "missing") {
176
+ return `${this.renderRefreshRequired(entry)}<div class="empty-state"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p></div>`;
177
+ }
178
+ if (state.kind === "invalid") {
179
+ return `
180
+ ${this.renderRefreshRequired(entry)}
181
+ <div class="status error" data-invalid-state>
182
+ <strong>${escapeHtml(state.message)}</strong>
183
+ <p>${escapeHtml(state.hint)}</p>
184
+ <pre class="diagnostic">${escapeHtml(state.detail)}</pre>
185
+ ${showReset ? `<button type="button" class="danger-secondary" data-reset-tasks-file>Reset Tasks File</button>` : ""}
186
+ </div>
187
+ `;
188
+ }
189
+ if (state.kind === "unavailable") {
190
+ return `
191
+ <div class="status error" data-unavailable-state>
192
+ <strong>${escapeHtml(state.message)}</strong>
193
+ <p>${escapeHtml(state.hint)}</p>
194
+ ${state.detail === undefined ? "" : `<pre class="diagnostic">${escapeHtml(state.detail)}</pre>`}
195
+ </div>
196
+ `;
197
+ }
198
+ if (state.config.tasks.length === 0) {
199
+ return `${this.renderRefreshRequired(entry)}<p class="muted">No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.</p>`;
200
+ }
201
+ return `
202
+ ${this.renderRefreshRequired(entry)}
203
+ <p class="muted">Tasks run as one script in one dedicated workspace terminal. Use <code>set -e</code> or <code>&amp;&amp;</code> when the script should stop after a failure.</p>
204
+ ${renderTaskGroups(state.config.tasks, this.runningTaskId, this.isRefreshRequired(entry) || this.operation !== undefined)}
205
+ `;
206
+ }
207
+ renderRefreshRequired(entry) {
208
+ if (!this.isRefreshRequired(entry))
209
+ return "";
210
+ return `<div class="status warning" data-refresh-required role="status">Refresh the workspace tasks file before making another change.</div>`;
211
+ }
212
+ renderStatus() {
213
+ if (this.status === undefined)
214
+ return "";
215
+ const detail = this.status.detail === undefined ? "" : `<pre class="diagnostic">${escapeHtml(this.status.detail)}</pre>`;
216
+ return `<div class="status panel-status ${escapeAttr(this.status.kind)}" data-panel-status role="status" aria-live="polite" tabindex="-1">${escapeHtml(this.status.message)}${detail}</div>`;
217
+ }
218
+ renderEditor(entry, locked) {
219
+ const editor = this.editor;
220
+ if (editor === undefined)
221
+ return "";
222
+ const validation = this.validateEditor(entry);
223
+ const errors = this.validationErrors ?? {};
224
+ const titleError = errors.title;
225
+ const commandError = errors.command;
226
+ const idError = errors.id;
227
+ const saveDisabled = locked
228
+ || this.operation !== undefined
229
+ || this.isRefreshRequired(entry)
230
+ || (entry.state.kind !== "loaded" && entry.state.kind !== "missing")
231
+ || !validation.ok;
232
+ const titleDescribedBy = titleError === undefined ? "" : ` aria-describedby="task-title-error"`;
233
+ const idDescribedBy = idError === undefined ? "" : ` aria-describedby="task-id-error"`;
234
+ const commandDescribedBy = commandError === undefined ? "task-command-help" : "task-command-help task-command-error";
235
+ const heading = editor.originalIndex === undefined ? "Add Task" : "Edit Task";
236
+ const failureRefresh = locked ? `<button type="button" class="secondary" data-refresh-after-failure>Refresh</button>` : "";
237
+ return `
238
+ <section class="task-editor" data-task-editor>
239
+ <h3>${heading}</h3>
240
+ <form class="task-form" data-task-form>
241
+ <label for="task-title">Title <span class="required" aria-hidden="true">*</span></label>
242
+ <input id="task-title" name="title" type="text" value="${escapeAttr(editor.draft.title)}" placeholder="Build app" data-editor-title aria-required="true"${titleError === undefined ? "" : ` aria-invalid="true"`}${titleDescribedBy} ${locked ? "disabled" : ""}>
243
+ ${renderFieldError("title", titleError)}
244
+
245
+ <label for="task-command">Command script <span class="required" aria-hidden="true">*</span></label>
246
+ <textarea id="task-command" name="command" data-editor-command aria-required="true" aria-describedby="${commandDescribedBy}"${commandError === undefined ? "" : ` aria-invalid="true"`}${locked ? " disabled" : ""}>${escapeHtml(editor.draft.command)}</textarea>
247
+ <p id="task-command-help" class="field-help">Runs once in one terminal through the server shell. Use <code>set -e</code> or <code>&amp;&amp;</code> for fail-fast behavior.</p>
248
+ ${renderFieldError("command", commandError)}
249
+
250
+ <label for="task-id">ID <span class="required" aria-hidden="true">*</span></label>
251
+ <input id="task-id" name="id" type="text" value="${escapeAttr(editor.draft.id)}" placeholder="Auto-generated from title" data-editor-id aria-required="true"${idError === undefined ? "" : ` aria-invalid="true"`}${idDescribedBy} ${locked ? "disabled" : ""}>
252
+ ${renderFieldError("id", idError)}
253
+
254
+ <label for="task-description">Description</label>
255
+ <input id="task-description" name="description" type="text" value="${escapeAttr(editor.draft.description)}" placeholder="Optional description" data-editor-description ${locked ? "disabled" : ""}>
256
+
257
+ <label for="task-group">Group</label>
258
+ <input id="task-group" name="group" type="text" value="${escapeAttr(editor.draft.group)}" placeholder="Optional group name" data-editor-group ${locked ? "disabled" : ""}>
259
+
260
+ <span class="checkbox-field">
261
+ <input id="task-confirm" name="confirm" type="checkbox" ${editor.draft.confirm ? "checked" : ""} data-editor-confirm ${locked ? "disabled" : ""}>
262
+ <label for="task-confirm">Require confirmation before running</label>
263
+ </span>
264
+
265
+ <div class="editor-actions">
266
+ <button type="button" class="secondary" data-cancel-editor>Cancel</button>
267
+ ${failureRefresh}
268
+ <button type="button" class="primary" data-save-task ${saveDisabled ? "disabled" : ""}>Save Task</button>
269
+ </div>
270
+ </form>
62
271
  </section>
63
- ${this.renderStatus()}
64
- <section class="viewer tasks-viewer">
65
- ${this.renderConfigState(state)}
272
+ `;
273
+ }
274
+ renderDeleteConfirmation(locked) {
275
+ const state = this.deleteState;
276
+ if (state === undefined)
277
+ return "";
278
+ return `
279
+ <section class="confirmation" data-delete-confirmation>
280
+ <h3>Delete Task</h3>
281
+ <p>Are you sure you want to delete <strong>${escapeHtml(state.task.title)}</strong>?</p>
282
+ <pre class="task-script">${escapeHtml(state.task.command)}</pre>
283
+ <div class="editor-actions">
284
+ <button type="button" class="secondary" data-cancel-delete>Cancel</button>
285
+ ${locked ? `<button type="button" class="secondary" data-refresh-after-failure>Refresh</button>` : `<button type="button" class="danger" data-confirm-delete ${this.operation === undefined ? "" : "disabled"}>Delete Task</button>`}
286
+ </div>
287
+ </section>
288
+ `;
289
+ }
290
+ renderResetConfirmation(locked) {
291
+ if (this.resetState === undefined)
292
+ return "";
293
+ return `
294
+ <section class="confirmation" data-reset-confirmation>
295
+ <h3>Reset Tasks File</h3>
296
+ <p>This replaces the invalid contents of <code>${escapeHtml(TASKS_CONFIG_PATH)}</code> with an empty version 1 tasks file.</p>
297
+ <div class="editor-actions">
298
+ <button type="button" class="secondary" data-cancel-reset>Cancel</button>
299
+ ${locked ? `<button type="button" class="secondary" data-refresh-after-failure>Refresh</button>` : `<button type="button" class="danger" data-confirm-reset ${this.operation === undefined ? "" : "disabled"}>Reset Tasks File</button>`}
300
+ </div>
301
+ </section>
302
+ `;
303
+ }
304
+ renderRefreshDiscardConfirmation() {
305
+ return `
306
+ <section class="confirmation" data-refresh-discard-confirmation>
307
+ <h3>Discard draft and refresh?</h3>
308
+ <p>Your unsaved task draft will be discarded. Refresh loads the authoritative workspace file.</p>
309
+ <div class="editor-actions">
310
+ <button type="button" class="secondary" data-cancel-refresh-discard>Cancel</button>
311
+ <button type="button" class="primary" data-confirm-refresh-discard>Discard &amp; Refresh</button>
312
+ </div>
66
313
  </section>
67
314
  `;
315
+ }
316
+ bindHandlers(context) {
317
+ this.root.querySelector("button[data-add-task]")?.addEventListener("click", () => {
318
+ this.openAddTaskEditor();
319
+ });
68
320
  this.root.querySelector("button[data-refresh-config]")?.addEventListener("click", () => {
69
- void this.refreshConfig(context);
321
+ void this.requestRefresh(context);
322
+ });
323
+ this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => {
324
+ this.openWorkspaceTerminal();
325
+ });
326
+ this.root.querySelector("button[data-reset-tasks-file]")?.addEventListener("click", () => {
327
+ this.openResetConfirmation(context);
328
+ });
329
+ this.root.querySelector("button[data-cancel-editor]")?.addEventListener("click", () => {
330
+ this.cancelEditor();
331
+ });
332
+ this.root.querySelector("button[data-save-task]")?.addEventListener("click", () => {
333
+ void this.saveTask(context);
334
+ });
335
+ this.root.querySelector("button[data-cancel-delete]")?.addEventListener("click", () => {
336
+ this.cancelDelete();
337
+ });
338
+ this.root.querySelector("button[data-confirm-delete]")?.addEventListener("click", () => {
339
+ void this.confirmDelete(context);
340
+ });
341
+ this.root.querySelector("button[data-cancel-reset]")?.addEventListener("click", () => {
342
+ this.cancelReset();
343
+ });
344
+ this.root.querySelector("button[data-confirm-reset]")?.addEventListener("click", () => {
345
+ void this.confirmReset(context);
346
+ });
347
+ this.root.querySelector("button[data-cancel-refresh-discard]")?.addEventListener("click", () => {
348
+ this.cancelRefreshDiscard();
349
+ });
350
+ this.root.querySelector("button[data-confirm-refresh-discard]")?.addEventListener("click", () => {
351
+ void this.confirmRefreshDiscard(context);
352
+ });
353
+ this.root.querySelector("button[data-refresh-after-failure]")?.addEventListener("click", () => {
354
+ void this.requestRefresh(context, true);
70
355
  });
71
356
  for (const button of this.root.querySelectorAll("button[data-task-id]")) {
72
357
  button.addEventListener("click", () => {
73
358
  void this.dispatchTaskById(context, button.getAttribute("data-task-id"));
74
359
  });
75
360
  }
76
- this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => {
77
- this.openWorkspaceTerminal();
361
+ for (const button of this.root.querySelectorAll("button[data-edit-task]")) {
362
+ button.addEventListener("click", () => {
363
+ this.openEditTaskEditor(context, button.getAttribute("data-edit-task"));
364
+ });
365
+ }
366
+ for (const button of this.root.querySelectorAll("button[data-delete-task]")) {
367
+ button.addEventListener("click", () => {
368
+ this.openDeleteConfirmation(context, button.getAttribute("data-delete-task"));
369
+ });
370
+ }
371
+ const title = this.root.querySelector("input[data-editor-title]");
372
+ title?.addEventListener("input", (event) => {
373
+ if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
374
+ return;
375
+ this.editor.draft.title = event.target.value;
376
+ if (!this.idManuallyEdited) {
377
+ this.editor.draft.id = suggestWorkspaceTaskId(event.target.value);
378
+ const id = this.root.querySelector("input[data-editor-id]");
379
+ if (id !== null)
380
+ id.value = this.editor.draft.id;
381
+ }
382
+ this.updateValidationInPlace(context);
383
+ });
384
+ const id = this.root.querySelector("input[data-editor-id]");
385
+ id?.addEventListener("input", (event) => {
386
+ if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
387
+ return;
388
+ this.editor.draft.id = event.target.value;
389
+ this.idManuallyEdited = true;
390
+ this.updateValidationInPlace(context);
391
+ });
392
+ const command = this.root.querySelector("textarea[data-editor-command]");
393
+ command?.addEventListener("input", (event) => {
394
+ if (!(event.target instanceof HTMLTextAreaElement) || this.editor === undefined)
395
+ return;
396
+ this.editor.draft.command = event.target.value;
397
+ this.updateValidationInPlace(context);
398
+ });
399
+ const description = this.root.querySelector("input[data-editor-description]");
400
+ description?.addEventListener("input", (event) => {
401
+ if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
402
+ return;
403
+ this.editor.draft.description = event.target.value;
404
+ this.updateValidationInPlace(context);
405
+ });
406
+ const group = this.root.querySelector("input[data-editor-group]");
407
+ group?.addEventListener("input", (event) => {
408
+ if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
409
+ return;
410
+ this.editor.draft.group = event.target.value;
411
+ this.updateValidationInPlace(context);
412
+ });
413
+ const confirm = this.root.querySelector("input[data-editor-confirm]");
414
+ confirm?.addEventListener("change", (event) => {
415
+ if (!(event.target instanceof HTMLInputElement) || this.editor === undefined)
416
+ return;
417
+ this.editor.draft.confirm = event.target.checked;
418
+ this.updateValidationInPlace(context);
419
+ });
420
+ this.root.querySelector("form[data-task-form]")?.addEventListener("submit", (event) => {
421
+ event.preventDefault();
422
+ void this.saveTask(context);
78
423
  });
79
424
  }
80
- dispatchTaskById(context, taskId) {
81
- if (!this.isCurrentContext(context))
82
- return Promise.resolve();
83
- const task = taskFromConfigState(getCachedWorkspaceConfig(context), taskId);
425
+ openAddTaskEditor() {
426
+ const context = this.contextValue;
427
+ if (context === undefined || this.operation !== undefined)
428
+ return;
429
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
430
+ if (entry === undefined || this.isRefreshRequired(entry) || (entry.state.kind !== "loaded" && entry.state.kind !== "missing"))
431
+ return;
432
+ const draft = emptyDraft();
433
+ this.editor = {
434
+ draft,
435
+ initialDraft: cloneDraft(draft),
436
+ sourceSnapshot: entry.state.snapshot,
437
+ originalIndex: undefined,
438
+ focusReturn: { kind: "add" },
439
+ };
440
+ this.mode = "add";
441
+ this.failure = undefined;
442
+ this.validationErrors = undefined;
443
+ this.idManuallyEdited = false;
444
+ this.status = undefined;
445
+ this.render();
446
+ this.focusSelector("input[data-editor-title]");
447
+ }
448
+ openEditTaskEditor(context, taskId) {
449
+ if (!this.isCurrentContext(context) || this.operation !== undefined || taskId === null)
450
+ return;
451
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
452
+ if (entry === undefined || this.isRefreshRequired(entry) || entry.state.kind !== "loaded")
453
+ return;
454
+ const index = entry.state.config.tasks.findIndex((task) => task.id === taskId);
455
+ const task = index < 0 ? undefined : entry.state.config.tasks[index];
84
456
  if (task === undefined) {
85
- this.status = { kind: "error", message: "That task is no longer available. Click Refresh, then try again." };
86
- this.render();
87
- return Promise.resolve();
457
+ this.setError("That task is no longer available. Click Refresh, then try again.");
458
+ return;
88
459
  }
89
- return this.dispatchTask(context, task);
460
+ const draft = {
461
+ id: task.id,
462
+ title: task.title,
463
+ command: task.command,
464
+ description: task.description ?? "",
465
+ group: task.group ?? "",
466
+ confirm: task.confirm,
467
+ };
468
+ this.editor = {
469
+ draft,
470
+ initialDraft: cloneDraft(draft),
471
+ sourceSnapshot: entry.state.snapshot,
472
+ originalIndex: index,
473
+ focusReturn: { kind: "edit", id: task.id },
474
+ };
475
+ this.mode = "edit";
476
+ this.failure = undefined;
477
+ this.validationErrors = undefined;
478
+ this.idManuallyEdited = true;
479
+ this.status = undefined;
480
+ this.render();
481
+ this.focusSelector("input[data-editor-title]");
90
482
  }
91
- isCurrentContext(context) {
92
- return this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
483
+ openDeleteConfirmation(context, taskId) {
484
+ if (!this.isCurrentContext(context) || this.operation !== undefined || taskId === null)
485
+ return;
486
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
487
+ if (entry === undefined || this.isRefreshRequired(entry) || entry.state.kind !== "loaded")
488
+ return;
489
+ const index = entry.state.config.tasks.findIndex((task) => task.id === taskId);
490
+ const task = index < 0 ? undefined : entry.state.config.tasks[index];
491
+ if (task === undefined) {
492
+ this.setError("That task is no longer available. Click Refresh, then try again.");
493
+ return;
494
+ }
495
+ this.deleteState = {
496
+ task,
497
+ index,
498
+ sourceSnapshot: entry.state.snapshot,
499
+ focusReturn: { kind: "delete", id: task.id },
500
+ };
501
+ this.mode = "delete-confirm";
502
+ this.failure = undefined;
503
+ this.status = undefined;
504
+ this.render();
505
+ this.focusSelector("button[data-cancel-delete]");
93
506
  }
94
- renderConfigState(state) {
95
- if (state.kind === "loading")
96
- return `<p class="muted">Loading ${escapeHtml(TASKS_CONFIG_PATH)}…</p>`;
97
- if (state.kind === "missing")
98
- return renderMissingState(state);
99
- if (state.kind === "unavailable")
100
- return renderUnavailableState(state);
101
- if (state.config.tasks.length === 0)
102
- return `<p class="muted">No tasks are defined in ${escapeHtml(state.path)}. Add tasks to the file, then click Refresh.</p>`;
103
- return `
104
- <p class="muted">Tasks run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(state.path)} and click Refresh to reload.</p>
105
- ${renderTaskGroups(state.config.tasks, this.runningTaskId)}
106
- `;
507
+ openResetConfirmation(context) {
508
+ if (!this.isCurrentContext(context) || this.operation !== undefined)
509
+ return;
510
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
511
+ if (entry === undefined || this.isRefreshRequired(entry) || entry.state.kind !== "invalid")
512
+ return;
513
+ this.resetState = { sourceSnapshot: entry.state.snapshot, focusReturn: { kind: "reset" } };
514
+ this.mode = "reset-confirm";
515
+ this.failure = undefined;
516
+ this.status = undefined;
517
+ this.render();
518
+ this.focusSelector("button[data-cancel-reset]");
107
519
  }
108
- renderStatus() {
109
- if (this.status === undefined)
110
- return "";
111
- const detail = this.status.detail === undefined ? "" : `<pre>${escapeHtml(this.status.detail)}</pre>`;
112
- return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
520
+ cancelEditor() {
521
+ if (this.operation !== undefined)
522
+ return;
523
+ const target = this.editor?.focusReturn;
524
+ this.editor = undefined;
525
+ this.validationErrors = undefined;
526
+ this.idManuallyEdited = false;
527
+ this.failure = undefined;
528
+ this.mode = "view";
529
+ this.status = undefined;
530
+ this.render();
531
+ this.focusTarget(target);
532
+ }
533
+ cancelDelete() {
534
+ if (this.operation !== undefined)
535
+ return;
536
+ const target = this.deleteState?.focusReturn;
537
+ this.deleteState = undefined;
538
+ this.failure = undefined;
539
+ this.mode = "view";
540
+ this.status = undefined;
541
+ this.render();
542
+ this.focusTarget(target);
113
543
  }
114
- async refreshConfig(context) {
115
- this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}…` };
116
- configCache.set(cacheKeyForContext(context), { kind: "loading" });
544
+ cancelReset() {
545
+ if (this.operation !== undefined)
546
+ return;
547
+ const target = this.resetState?.focusReturn;
548
+ this.resetState = undefined;
549
+ this.failure = undefined;
550
+ this.mode = "view";
551
+ this.status = undefined;
117
552
  this.render();
118
- const state = await refreshWorkspaceConfig(context);
119
- if (!this.isCurrentContext(context))
553
+ this.focusTarget(target);
554
+ }
555
+ cancelRefreshDiscard() {
556
+ if (this.operation !== undefined)
120
557
  return;
121
- this.status = state.kind === "loaded"
122
- ? { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` }
123
- : undefined;
558
+ const target = this.editor?.focusReturn;
559
+ this.mode = this.editor === undefined ? "view" : this.editor.originalIndex === undefined ? "add" : "edit";
560
+ this.pendingRefreshFocus = undefined;
124
561
  this.render();
562
+ this.focusTarget(target);
125
563
  }
126
- async dispatchTask(context, task) {
127
- if (this.runningTaskId !== undefined) {
128
- this.status = { kind: "info", message: "Another task is already starting. Wait for it to finish dispatching, then try again." };
564
+ async confirmRefreshDiscard(context) {
565
+ if (this.mode !== "refresh-discard-confirm")
566
+ return;
567
+ const target = this.pendingRefreshFocus ?? { kind: "refresh" };
568
+ this.editor = undefined;
569
+ this.deleteState = undefined;
570
+ this.resetState = undefined;
571
+ this.failure = undefined;
572
+ this.validationErrors = undefined;
573
+ this.idManuallyEdited = false;
574
+ this.pendingRefreshFocus = target;
575
+ await this.requestRefresh(context, true);
576
+ }
577
+ async requestRefresh(context, force = false) {
578
+ if (!this.isCurrentContext(context) || this.operation !== undefined)
579
+ return;
580
+ if (!force && this.isConfirmationMode())
581
+ return;
582
+ if (!force && (this.mode === "add" || this.mode === "edit") && this.editor !== undefined && this.isEditorDirty()) {
583
+ this.pendingRefreshFocus = this.editor.focusReturn;
584
+ this.mode = "refresh-discard-confirm";
585
+ this.status = undefined;
129
586
  this.render();
587
+ this.focusSelector("button[data-cancel-refresh-discard]");
130
588
  return;
131
589
  }
590
+ const target = this.pendingRefreshFocus ?? this.editor?.focusReturn ?? { kind: "refresh" };
591
+ this.pendingRefreshFocus = undefined;
592
+ this.editor = undefined;
593
+ this.deleteState = undefined;
594
+ this.resetState = undefined;
595
+ this.failure = undefined;
596
+ this.validationErrors = undefined;
597
+ this.idManuallyEdited = false;
598
+ this.mode = "view";
599
+ this.operation = "refresh";
600
+ const operationGeneration = ++this.operationGeneration;
601
+ const selectionGeneration = this.selectionGeneration;
602
+ this.status = { kind: "info", message: `Refreshing ${TASKS_CONFIG_PATH}...` };
603
+ this.render();
604
+ try {
605
+ const state = await refreshWorkspaceTasksConfig(context.files, cacheKeyForContext(context));
606
+ if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
607
+ return;
608
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
609
+ if (entry?.state !== state) {
610
+ this.operation = undefined;
611
+ this.status = undefined;
612
+ this.render();
613
+ return;
614
+ }
615
+ this.panelRefreshRequired = false;
616
+ this.operation = undefined;
617
+ this.status = refreshStatus(state);
618
+ this.render();
619
+ this.focusTarget(target);
620
+ }
621
+ catch (error) {
622
+ if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
623
+ return;
624
+ this.operation = undefined;
625
+ this.status = { kind: "error", message: `Could not refresh ${TASKS_CONFIG_PATH}.`, detail: formatError(error) };
626
+ this.render();
627
+ }
628
+ }
629
+ async saveTask(context) {
630
+ if (!this.isCurrentContext(context) || this.operation !== undefined || this.editor === undefined || (this.mode !== "add" && this.mode !== "edit"))
631
+ return;
632
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
633
+ if (entry === undefined)
634
+ return;
635
+ const validation = this.validateEditor(entry);
636
+ if (!validation.ok) {
637
+ this.validationErrors = validation.errors;
638
+ this.status = { kind: "error", message: "Fix the highlighted fields before saving." };
639
+ this.render();
640
+ this.focusFirstInvalid(validation.errors);
641
+ return;
642
+ }
643
+ if (entry.state.kind !== "loaded" && entry.state.kind !== "missing") {
644
+ this.enterFailure("preflight-unavailable", "The current tasks file could not be verified. Refresh before trying again.", "editor");
645
+ return;
646
+ }
647
+ const editor = this.editor;
648
+ const nextConfig = this.mode === "add"
649
+ ? appendWorkspaceTask(entry.state.kind === "loaded" ? entry.state.config : emptyWorkspaceTasksConfig, validation.task)
650
+ : this.buildEditedConfig(entry, validation.task);
651
+ if (nextConfig === undefined)
652
+ return;
653
+ const operationGeneration = ++this.operationGeneration;
654
+ const selectionGeneration = this.selectionGeneration;
655
+ this.operation = "mutation";
656
+ this.status = { kind: "info", message: "Saving workspace task..." };
657
+ this.render();
658
+ try {
659
+ const result = await guardedWriteWorkspaceTasksConfig(context.files, cacheKeyForContext(context), editor.sourceSnapshot, nextConfig);
660
+ if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
661
+ return;
662
+ this.operation = undefined;
663
+ if (result.kind === "written") {
664
+ const title = validation.task.title;
665
+ this.mode = "view";
666
+ this.editor = undefined;
667
+ this.validationErrors = undefined;
668
+ this.idManuallyEdited = false;
669
+ this.failure = undefined;
670
+ this.status = { kind: "success", message: `Saved task "${title}".` };
671
+ this.render();
672
+ this.focusTarget(editor.focusReturn);
673
+ return;
674
+ }
675
+ this.applyWriteFailure(result, "editor");
676
+ }
677
+ catch (error) {
678
+ if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
679
+ return;
680
+ this.operation = undefined;
681
+ this.applyWriteFailure({ kind: "write-failed", detail: `Unable to write ${TASKS_CONFIG_PATH}: ${formatError(error)}` }, "editor");
682
+ }
683
+ }
684
+ buildEditedConfig(entry, task) {
685
+ const editor = this.editor;
686
+ if (entry.state.kind !== "loaded" || editor?.originalIndex === undefined)
687
+ return undefined;
688
+ const original = entry.state.config.tasks[editor.originalIndex];
689
+ if (original?.id !== editor.initialDraft.id) {
690
+ this.enterFailure("conflict", "The task list changed outside this panel. Refresh before trying again.", "editor");
691
+ return undefined;
692
+ }
693
+ return replaceWorkspaceTaskAt(entry.state.config, editor.originalIndex, task);
694
+ }
695
+ async confirmDelete(context) {
696
+ if (!this.isCurrentContext(context) || this.operation !== undefined || this.deleteState === undefined || this.mode !== "delete-confirm")
697
+ return;
698
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
699
+ const pending = this.deleteState;
700
+ if (entry?.state.kind !== "loaded" || this.isRefreshRequired(entry) || entry.state.config.tasks[pending.index]?.id !== pending.task.id) {
701
+ this.enterFailure("conflict", "The task list changed outside this panel. Refresh before trying again.", "delete");
702
+ return;
703
+ }
704
+ let nextConfig;
705
+ try {
706
+ nextConfig = removeWorkspaceTaskAt(entry.state.config, pending.index);
707
+ }
708
+ catch (error) {
709
+ this.enterFailure("conflict", formatError(error), "delete");
710
+ return;
711
+ }
712
+ await this.performMutation(context, pending.sourceSnapshot, nextConfig, "delete", pending.task.title, pending.focusReturn);
713
+ }
714
+ async confirmReset(context) {
715
+ if (!this.isCurrentContext(context) || this.operation !== undefined || this.resetState === undefined || this.mode !== "reset-confirm")
716
+ return;
717
+ const pending = this.resetState;
718
+ await this.performMutation(context, pending.sourceSnapshot, emptyWorkspaceTasksConfig, "reset", undefined, pending.focusReturn);
719
+ }
720
+ async performMutation(context, sourceSnapshot, nextConfig, action, title, focusReturn) {
721
+ const operationGeneration = ++this.operationGeneration;
722
+ const selectionGeneration = this.selectionGeneration;
723
+ this.operation = "mutation";
724
+ this.status = { kind: "info", message: action === "delete" ? "Deleting workspace task..." : action === "reset" ? "Resetting workspace tasks file..." : "Saving workspace task..." };
725
+ this.render();
726
+ try {
727
+ const result = await guardedWriteWorkspaceTasksConfig(context.files, cacheKeyForContext(context), sourceSnapshot, nextConfig);
728
+ if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
729
+ return;
730
+ this.operation = undefined;
731
+ if (result.kind === "written") {
732
+ this.mode = "view";
733
+ this.editor = undefined;
734
+ this.deleteState = undefined;
735
+ this.resetState = undefined;
736
+ this.failure = undefined;
737
+ this.validationErrors = undefined;
738
+ this.idManuallyEdited = false;
739
+ this.status = action === "delete" && title !== undefined
740
+ ? { kind: "success", message: `Deleted task "${title}".` }
741
+ : action === "reset"
742
+ ? { kind: "success", message: "Reset workspace tasks file." }
743
+ : { kind: "success", message: "Saved workspace task." };
744
+ this.render();
745
+ this.focusTarget(focusReturn);
746
+ return;
747
+ }
748
+ this.applyWriteFailure(result, action === "delete" ? "delete" : "reset");
749
+ }
750
+ catch (error) {
751
+ if (!this.ownsOperation(context, selectionGeneration, operationGeneration))
752
+ return;
753
+ this.operation = undefined;
754
+ this.applyWriteFailure({ kind: "write-failed", detail: `Unable to write ${TASKS_CONFIG_PATH}: ${formatError(error)}` }, action === "delete" ? "delete" : "reset");
755
+ }
756
+ }
757
+ applyWriteFailure(result, action) {
758
+ this.panelRefreshRequired = true;
759
+ this.failure = { kind: result.kind, detail: result.detail, action };
760
+ this.mode = result.kind === "conflict" || result.kind === "preflight-unavailable" ? "conflicted" : "needs-refresh-after-write";
761
+ this.status = { kind: "error", message: failureMessage(result.kind), detail: result.detail };
762
+ this.render();
763
+ }
764
+ enterFailure(kind, detail, action) {
765
+ this.panelRefreshRequired = true;
766
+ this.failure = { kind, detail, action };
767
+ this.mode = kind === "conflict" || kind === "preflight-unavailable" ? "conflicted" : "needs-refresh-after-write";
768
+ this.status = { kind: "error", message: failureMessage(kind), detail };
769
+ this.render();
770
+ }
771
+ dispatchTaskById(context, taskId) {
772
+ if (!this.isCurrentContext(context) || taskId === null)
773
+ return Promise.resolve();
774
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
775
+ const task = entry?.state.kind === "loaded" ? entry.state.config.tasks.find((candidate) => candidate.id === taskId) : undefined;
776
+ if (task === undefined) {
777
+ this.setError("That task is no longer available. Click Refresh, then try again.");
778
+ return Promise.resolve();
779
+ }
780
+ return this.dispatchTask(context, task);
781
+ }
782
+ async dispatchTask(context, task) {
783
+ if (this.runningTaskId !== undefined || this.operation !== undefined)
784
+ return;
132
785
  if (task.confirm && !window.confirm(`Run ${task.title}?\n\n${task.command}`)) {
133
786
  this.status = { kind: "info", message: `Cancelled ${task.title}.` };
134
787
  this.render();
135
788
  return;
136
789
  }
790
+ const selectionGeneration = this.selectionGeneration;
791
+ const terminalRunGeneration = ++this.terminalRunGeneration;
137
792
  this.runningTaskId = task.id;
138
- this.status = { kind: "info", message: `Starting ${task.title}…` };
793
+ this.status = { kind: "info", message: `Starting ${task.title}...` };
139
794
  this.render();
140
795
  try {
141
796
  const handle = await runWorkspaceTaskInTerminal(context.terminal, task);
142
- if (!this.isCurrentContext(context))
797
+ if (!this.ownsTerminalRun(context, selectionGeneration, terminalRunGeneration))
143
798
  return;
144
- this.status = {
145
- kind: "success",
146
- message: `Started terminal command “${handle.run.title}”.`,
147
- detail: task.command,
148
- };
149
799
  this.runningTaskId = undefined;
150
- this.render();
800
+ this.renderTerminalStatus({ kind: "success", message: `Started terminal command "${handle.run.title}".`, detail: task.command });
151
801
  }
152
802
  catch (error) {
153
- if (!this.isCurrentContext(context))
803
+ if (!this.ownsTerminalRun(context, selectionGeneration, terminalRunGeneration))
154
804
  return;
155
805
  this.runningTaskId = undefined;
156
- this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
806
+ this.renderTerminalStatus({ kind: "error", message: formatError(error) });
807
+ }
808
+ }
809
+ renderTerminalStatus(status) {
810
+ if (this.operation !== undefined) {
157
811
  this.render();
812
+ return;
158
813
  }
814
+ this.status = status;
815
+ this.render();
159
816
  }
160
817
  openWorkspaceTerminal(terminalId) {
161
818
  const context = this.contextValue;
162
819
  if (context === undefined) {
163
- this.status = { kind: "error", message: "Select a workspace before opening a terminal." };
164
- this.render();
820
+ this.setError("Select a workspace before opening a terminal.");
165
821
  return;
166
822
  }
167
823
  if (terminalId === undefined)
@@ -169,102 +825,234 @@ class PiWebUiTasksPanel extends HTMLElement {
169
825
  else
170
826
  context.terminal.open({ terminalId });
171
827
  }
828
+ validateEditor(entry) {
829
+ const editor = this.editor;
830
+ if (editor === undefined)
831
+ return { ok: false, errors: { title: "Title is required." } };
832
+ const existingTasks = entry.state.kind === "loaded" ? entry.state.config.tasks : [];
833
+ return validateAndNormalizeDraft(editor.draft, existingTasks, editor.originalIndex);
834
+ }
835
+ updateValidationInPlace(context) {
836
+ const entry = getWorkspaceTasksCacheEntry(cacheKeyForContext(context));
837
+ if (entry === undefined || this.editor === undefined)
838
+ return;
839
+ const result = this.validateEditor(entry);
840
+ this.validationErrors = result.ok ? undefined : result.errors;
841
+ const errors = this.validationErrors ?? {};
842
+ const controls = [["title", "task-title"], ["command", "task-command"], ["id", "task-id"]];
843
+ for (const [field, controlId] of controls) {
844
+ const control = this.root.querySelector(`#${controlId}`);
845
+ const error = errors[field];
846
+ const errorElement = this.root.querySelector(`[data-field-error="${field}"]`);
847
+ if (control === null || errorElement === null)
848
+ continue;
849
+ if (error === undefined) {
850
+ control.removeAttribute("aria-invalid");
851
+ if (field === "command")
852
+ control.setAttribute("aria-describedby", "task-command-help");
853
+ else
854
+ control.removeAttribute("aria-describedby");
855
+ errorElement.textContent = "";
856
+ errorElement.hidden = true;
857
+ }
858
+ else {
859
+ control.setAttribute("aria-invalid", "true");
860
+ control.setAttribute("aria-describedby", field === "command" ? `task-command-help task-command-error` : `task-${field}-error`);
861
+ errorElement.textContent = error;
862
+ errorElement.hidden = false;
863
+ }
864
+ }
865
+ const save = this.root.querySelector("button[data-save-task]");
866
+ if (save !== null)
867
+ save.disabled = this.operation !== undefined || this.isRefreshRequired(entry) || !result.ok || this.failure !== undefined;
868
+ }
869
+ isEditorDirty() {
870
+ const editor = this.editor;
871
+ if (editor === undefined)
872
+ return false;
873
+ return editor.draft.id !== editor.initialDraft.id
874
+ || editor.draft.title !== editor.initialDraft.title
875
+ || editor.draft.command !== editor.initialDraft.command
876
+ || editor.draft.description !== editor.initialDraft.description
877
+ || editor.draft.group !== editor.initialDraft.group
878
+ || editor.draft.confirm !== editor.initialDraft.confirm;
879
+ }
880
+ focusFirstInvalid(errors) {
881
+ const field = errors.title === undefined ? errors.command === undefined ? "id" : "command" : "title";
882
+ this.focusSelector(`input[data-editor-${field}], textarea[data-editor-${field}]`);
883
+ }
884
+ focusSelector(selector) {
885
+ if (!this.connected)
886
+ return;
887
+ this.root.querySelector(selector)?.focus();
888
+ }
889
+ focusTarget(target) {
890
+ if (!this.connected)
891
+ return;
892
+ let element;
893
+ if (target?.kind === "add")
894
+ element = this.root.querySelector("button[data-add-task]") ?? undefined;
895
+ if (target?.kind === "edit")
896
+ element = findButtonByValue(this.root, "data-edit-task", target.id);
897
+ if (target?.kind === "delete")
898
+ element = findButtonByValue(this.root, "data-delete-task", target.id);
899
+ if (target?.kind === "reset")
900
+ element = this.root.querySelector("button[data-reset-tasks-file]") ?? undefined;
901
+ if (target?.kind === "refresh")
902
+ element = this.root.querySelector("button[data-refresh-config]") ?? undefined;
903
+ if (target?.kind === "heading" || element === undefined)
904
+ element = this.root.querySelector("[data-panel-heading]") ?? undefined;
905
+ element?.focus();
906
+ }
907
+ isCurrentContext(context) {
908
+ return this.connected && this.contextValue !== undefined && cacheKeyForContext(this.contextValue) === cacheKeyForContext(context);
909
+ }
910
+ ownsTerminalRun(context, selectionGeneration, terminalRunGeneration) {
911
+ return this.isCurrentContext(context)
912
+ && this.selectionGeneration === selectionGeneration
913
+ && this.terminalRunGeneration === terminalRunGeneration;
914
+ }
915
+ ownsOperation(context, selectionGeneration, operationGeneration) {
916
+ return this.isCurrentContext(context)
917
+ && this.selectionGeneration === selectionGeneration
918
+ && this.operationGeneration === operationGeneration;
919
+ }
920
+ setError(message) {
921
+ this.status = { kind: "error", message };
922
+ this.render();
923
+ }
172
924
  }
173
- function getCachedWorkspaceConfig(context) {
174
- return configCache.get(cacheKeyForContext(context));
175
- }
176
- function getOrLoadWorkspaceConfig(context) {
177
- const cached = getCachedWorkspaceConfig(context);
178
- if (cached !== undefined)
179
- return cached;
180
- const loading = { kind: "loading" };
181
- configCache.set(cacheKeyForContext(context), loading);
182
- void refreshWorkspaceConfig(context);
183
- return loading;
184
- }
185
- async function refreshWorkspaceConfig(context) {
186
- const key = cacheKeyForContext(context);
187
- const state = await loadWorkspaceTasksConfig(context.files).catch((error) => ({
188
- kind: "unavailable",
189
- message: tasksConfigUnavailableMessage,
190
- hint: tasksConfigRefreshHint,
191
- detail: error instanceof Error ? error.message : String(error),
192
- }));
193
- configCache.set(key, state);
194
- context.host.requestRender();
195
- window.dispatchEvent(new Event(configChangedEvent));
196
- return state;
197
- }
198
- function cacheKeyForContext(context) {
199
- return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
200
- }
201
- function renderMissingState(state) {
202
- return `<div class="empty-state"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p></div>`;
203
- }
204
- function renderUnavailableState(state) {
205
- const detail = state.detail === undefined ? "" : `<pre>${escapeHtml(state.detail)}</pre>`;
206
- return `<div class="status error"><strong>${escapeHtml(state.message)}</strong><p>${escapeHtml(state.hint)}</p>${detail}</div>`;
925
+ function renderFieldError(field, error) {
926
+ return `<p id="task-${field}-error" data-field-error="${field}" class="field-error"${error === undefined ? " hidden" : ""}>${error === undefined ? "" : escapeHtml(error)}</p>`;
207
927
  }
208
- function renderTaskGroups(tasks, runningTaskId) {
209
- return `<div class="tasks">${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId)).join("")}</div>`;
928
+ function renderTaskGroups(tasks, runningTaskId, actionsDisabled) {
929
+ return `<div class="tasks">${groupTasks(tasks).map((group) => renderTaskGroup(group, runningTaskId, actionsDisabled)).join("")}</div>`;
210
930
  }
211
931
  function groupTasks(tasks) {
212
932
  const groups = [];
213
933
  for (const task of tasks) {
214
- const title = task.group;
215
- let group = groups.find((candidate) => candidate.title === title);
934
+ let group = groups.find((candidate) => candidate.title === task.group);
216
935
  if (group === undefined) {
217
- group = { title, tasks: [] };
936
+ group = { title: task.group, tasks: [] };
218
937
  groups.push(group);
219
938
  }
220
939
  group.tasks.push(task);
221
940
  }
222
941
  return groups;
223
942
  }
224
- function renderTaskGroup(group, runningTaskId) {
943
+ function renderTaskGroup(group, runningTaskId, actionsDisabled) {
225
944
  const title = group.title === undefined ? "" : `<h3>${escapeHtml(group.title)}</h3>`;
226
- return `<section class="task-group">${title}${group.tasks.map((task) => renderTask(task, runningTaskId)).join("")}</section>`;
945
+ return `<section class="task-group">${title}${group.tasks.map((task) => renderTask(task, runningTaskId, actionsDisabled)).join("")}</section>`;
227
946
  }
228
- function renderTask(task, runningTaskId) {
947
+ function renderTask(task, runningTaskId, actionsDisabled) {
229
948
  const running = runningTaskId === task.id;
230
- const disabled = runningTaskId !== undefined;
949
+ const runDisabled = runningTaskId !== undefined || actionsDisabled;
950
+ const mutationDisabled = runningTaskId !== undefined || actionsDisabled;
231
951
  const description = task.description === undefined ? "" : `<span>${escapeHtml(task.description)}</span>`;
232
952
  return `
233
953
  <article class="task-card">
234
954
  <div class="task-copy">
235
955
  <strong>${escapeHtml(task.title)}</strong>
236
956
  ${description}
237
- <code>${escapeHtml(task.command)}</code>
957
+ <pre class="task-script" data-task-script>${escapeHtml(task.command)}</pre>
958
+ </div>
959
+ <div class="task-actions">
960
+ <button type="button" class="secondary" data-edit-task="${escapeAttr(task.id)}" ${mutationDisabled ? "disabled" : ""}>Edit</button>
961
+ <button type="button" class="secondary danger-secondary" data-delete-task="${escapeAttr(task.id)}" ${mutationDisabled ? "disabled" : ""}>Delete</button>
962
+ <button type="button" data-task-id="${escapeAttr(task.id)}" ${runDisabled ? "disabled" : ""}>${running ? "Dispatching..." : "Run"}</button>
238
963
  </div>
239
- <button data-task-id="${escapeAttr(task.id)}" ${disabled ? "disabled" : ""}>${running ? "Dispatching…" : "Run"}</button>
240
964
  </article>
241
965
  `;
242
966
  }
243
- function taskFromConfigState(state, taskId) {
244
- if (state?.kind !== "loaded" || taskId === null)
245
- return undefined;
246
- return state.config.tasks.find((task) => task.id === taskId);
967
+ function findButtonByValue(root, attribute, value) {
968
+ for (const button of root.querySelectorAll(`button[${attribute}]`)) {
969
+ if (button.getAttribute(attribute) === value)
970
+ return button;
971
+ }
972
+ return undefined;
973
+ }
974
+ function emptyDraft() {
975
+ return { id: "", title: "", command: "", description: "", group: "", confirm: false };
976
+ }
977
+ function cloneDraft(draft) {
978
+ return { ...draft };
979
+ }
980
+ function cacheKeyForContext(context) {
981
+ return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
982
+ }
983
+ function refreshStatus(state) {
984
+ if (state.kind === "loaded")
985
+ return { kind: "success", message: `Loaded ${String(state.config.tasks.length)} task${state.config.tasks.length === 1 ? "" : "s"}.` };
986
+ if (state.kind === "missing")
987
+ return { kind: "info", message: "No workspace tasks file is configured." };
988
+ if (state.kind === "invalid")
989
+ return { kind: "error", message: "Workspace tasks configuration is invalid.", detail: state.detail };
990
+ const detail = state.detail;
991
+ return detail === undefined
992
+ ? { kind: "error", message: state.message }
993
+ : { kind: "error", message: state.message, detail };
994
+ }
995
+ function failureMessage(kind) {
996
+ if (kind === "conflict")
997
+ return "The tasks file changed outside this panel. Refresh before trying again.";
998
+ if (kind === "preflight-unavailable")
999
+ return "The current tasks file could not be verified. Refresh before trying again.";
1000
+ if (kind === "write-failed")
1001
+ return "The task write failed. Refresh before trying again; the result may be unknown.";
1002
+ return "The task was written, but the new file could not be verified. Refresh before trying again.";
1003
+ }
1004
+ function formatError(error) {
1005
+ return error instanceof Error ? error.message : String(error);
1006
+ }
1007
+ function escapeHtml(value) {
1008
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1009
+ }
1010
+ function escapeAttr(value) {
1011
+ return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
247
1012
  }
248
1013
  function taskStyles() {
249
1014
  return `
250
1015
  <style>
251
- :host { display: contents; }
1016
+ :host { display: block; min-width: 0; container-type: inline-size; }
1017
+ .tasks-panel { min-width: 0; container-type: inline-size; color: var(--pi-text); }
252
1018
  .toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
1019
+ .toolbar h2 { margin: 0; font-size: 15px; }
253
1020
  .toolbar-tasks { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
254
- .viewer { box-sizing: border-box; min-height: 0; overflow: auto; padding: 12px; }
1021
+ .viewer { box-sizing: border-box; min-width: 0; min-height: 0; overflow: auto; padding: 12px; }
255
1022
  .tasks-viewer { display: grid; align-content: start; gap: 12px; }
256
- .tasks { display: grid; gap: 14px; }
257
- .task-group { display: grid; gap: 10px; }
1023
+ .tasks { display: grid; gap: 14px; min-width: 0; }
1024
+ .task-group { display: grid; gap: 10px; min-width: 0; }
258
1025
  .task-group h3 { margin: 4px 0 0; color: var(--pi-text-secondary); font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; }
259
- .task-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
1026
+ .task-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: start; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); padding: 12px; }
260
1027
  .task-copy { display: grid; min-width: 0; gap: 5px; }
261
- .task-copy span, .muted { color: var(--pi-muted); }
262
- code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
263
- code { overflow: auto; padding: 5px 7px; white-space: nowrap; }
264
- pre { margin: 8px 0 0; overflow: auto; padding: 8px; white-space: pre-wrap; }
1028
+ .task-copy span, .muted, .field-help { color: var(--pi-muted); }
1029
+ .task-script, .diagnostic { box-sizing: border-box; max-width: 100%; margin: 0; border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
1030
+ .task-script { max-height: 12rem; overflow-y: auto; padding: 8px; }
1031
+ .diagnostic { max-height: 12rem; overflow: auto; padding: 8px; }
1032
+ code { font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
265
1033
  button { border: 1px solid var(--pi-accent-border); border-radius: 7px; background: var(--pi-accent); color: var(--pi-bg); cursor: pointer; padding: 6px 10px; font: inherit; }
266
1034
  button.secondary { border-color: var(--pi-border); background: var(--pi-surface); color: var(--pi-text); }
1035
+ button.danger, button.danger-secondary { border-color: var(--pi-danger); }
1036
+ button.danger { background: var(--pi-danger); color: var(--pi-bg); }
1037
+ button.danger-secondary { background: var(--pi-surface); color: var(--pi-danger); }
267
1038
  button:disabled { cursor: wait; opacity: 0.65; }
1039
+ button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible { outline: 2px solid var(--pi-accent-border); outline-offset: 2px; }
1040
+ .task-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
1041
+ .task-editor, .confirmation { width: min(100%, 620px); box-sizing: border-box; padding: 16px; background: var(--pi-surface); border: 1px solid var(--pi-border); border-radius: 8px; }
1042
+ .task-editor h3, .confirmation h3 { margin: 0 0 16px; font-size: 18px; }
1043
+ .confirmation p { margin: 0 0 10px; line-height: 1.5; }
1044
+ .confirmation .task-script { margin: 10px 0 16px; }
1045
+ .task-form { display: grid; gap: 6px; }
1046
+ .task-form > label, .checkbox-field > label { font-size: 14px; font-weight: 500; }
1047
+ .task-form .required { color: var(--pi-danger); }
1048
+ .task-form input[type="text"], .task-form textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; background: var(--pi-bg); border: 1px solid var(--pi-border); border-radius: 6px; font-size: 14px; color: var(--pi-text); font-family: inherit; }
1049
+ .task-form textarea { min-height: 9rem; max-height: 24rem; resize: vertical; line-height: 1.45; }
1050
+ .task-form input[type="text"]::placeholder, .task-form textarea::placeholder { color: var(--pi-muted); }
1051
+ .checkbox-field { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px; margin-top: 8px; }
1052
+ .checkbox-field input { width: 18px; height: 18px; }
1053
+ .field-help { margin: 0 0 8px; font-size: 12px; line-height: 1.4; }
1054
+ .field-error { margin: 0 0 8px; color: var(--pi-danger); font-size: 12px; }
1055
+ .editor-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; }
268
1056
  .empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
269
1057
  .empty-state p { margin: 6px 0 0; }
270
1058
  .panel-status { margin: 12px 12px 0; }
@@ -272,17 +1060,13 @@ function taskStyles() {
272
1060
  .status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
273
1061
  .status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); }
274
1062
  .status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
1063
+ .status.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); }
1064
+ .status p { margin: 6px 0; }
275
1065
  .empty { padding: 16px; color: var(--pi-muted); }
276
- @media (max-width: 760px) {
277
- .task-card { grid-template-columns: 1fr; }
278
- .task-card button { justify-self: start; }
1066
+ @container (max-width: 600px) {
1067
+ .task-card { grid-template-columns: minmax(0, 1fr); }
1068
+ .task-actions { justify-content: flex-start; }
279
1069
  }
280
1070
  </style>
281
1071
  `;
282
1072
  }
283
- function escapeHtml(value) {
284
- return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
285
- }
286
- function escapeAttr(value) {
287
- return escapeHtml(value).replaceAll('"', "&quot;");
288
- }