dsh-taskboard 0.1.1 → 0.2.0

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/client.js CHANGED
@@ -37,6 +37,7 @@ window.__ModuleLoader__.load({
37
37
  comment: (id, bodyText) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/comment`, { body: bodyText }),
38
38
  remove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/delete`, body),
39
39
  run: (id) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/run`, {}),
40
+ cancel: (id) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/cancel`, {}),
40
41
  stream(onChange, onGap) {
41
42
  const es = new EventSource("/dsh-taskboard/events");
42
43
  let revision;
@@ -205,13 +206,43 @@ window.__ModuleLoader__.load({
205
206
 
206
207
  //#endregion
207
208
  //#region src/client/controller.ts
208
- /** Instantiate the default state. */
209
+ /** localStorage key for persisted view state (filters + sort). */
210
+ const VIEW_KEY = "dsh-taskboard-view-v1";
211
+ /** Load the persisted view state (never throws; fresh on any parse error). */
212
+ function loadView() {
213
+ try {
214
+ const raw = localStorage.getItem(VIEW_KEY);
215
+ if (raw === null) return {
216
+ urgencies: [],
217
+ sortBy: "default"
218
+ };
219
+ const parsed = JSON.parse(raw);
220
+ const sortBy = parsed.sortBy === "updated" || parsed.sortBy === "urgency" || parsed.sortBy === "created" ? parsed.sortBy : "default";
221
+ return {
222
+ workspaceId: typeof parsed.workspaceId === "string" ? parsed.workspaceId : void 0,
223
+ urgencies: Array.isArray(parsed.urgencies) ? parsed.urgencies.filter((u) => u === "urgent" || u === "normal" || u === "relaxed") : [],
224
+ sortBy
225
+ };
226
+ } catch {
227
+ return {
228
+ urgencies: [],
229
+ sortBy: "default"
230
+ };
231
+ }
232
+ }
233
+ /** Instantiate the default state (view state hydrated from localStorage). */
209
234
  function initialState() {
235
+ const view = loadView();
210
236
  return {
211
237
  boardOpen: false,
212
238
  ledger: emptyLedger(),
213
239
  workspaces: [],
214
- filters: { urgencies: [] },
240
+ filters: {
241
+ workspaceId: view.workspaceId,
242
+ urgencies: view.urgencies
243
+ },
244
+ search: "",
245
+ sortBy: view.sortBy,
215
246
  composerOpen: false,
216
247
  secondaryOpen: false
217
248
  };
@@ -226,6 +257,7 @@ window.__ModuleLoader__.load({
226
257
  disposed = false;
227
258
  disposeStream;
228
259
  refreshInFlight;
260
+ sessionJumper;
229
261
  /** @param client - the route client. */
230
262
  constructor(client) {
231
263
  this.client = client;
@@ -303,14 +335,15 @@ window.__ModuleLoader__.load({
303
335
  toggleBoard() {
304
336
  this.setState({ boardOpen: !this.state.boardOpen });
305
337
  }
306
- /** Set the project filter. */
338
+ /** Set the project filter (persisted). */
307
339
  setWorkspaceFilter(workspaceId) {
308
340
  this.setState({ filters: {
309
341
  ...this.state.filters,
310
342
  workspaceId
311
343
  } });
344
+ this.persistView();
312
345
  }
313
- /** Toggle one urgency chip. */
346
+ /** Toggle one urgency chip (persisted). */
314
347
  toggleUrgency(urgency) {
315
348
  const set = new Set(this.state.filters.urgencies);
316
349
  if (set.has(urgency)) set.delete(urgency);
@@ -319,6 +352,26 @@ window.__ModuleLoader__.load({
319
352
  ...this.state.filters,
320
353
  urgencies: [...set]
321
354
  } });
355
+ this.persistView();
356
+ }
357
+ /** Set the free-text search (transient — not persisted). */
358
+ setSearch(search) {
359
+ this.setState({ search });
360
+ }
361
+ /** Set the column sort order (persisted). */
362
+ setSortBy(sortBy) {
363
+ this.setState({ sortBy });
364
+ this.persistView();
365
+ }
366
+ /** Write the current view state to localStorage (best effort). */
367
+ persistView() {
368
+ try {
369
+ localStorage.setItem(VIEW_KEY, JSON.stringify({
370
+ workspaceId: this.state.filters.workspaceId,
371
+ urgencies: this.state.filters.urgencies,
372
+ sortBy: this.state.sortBy
373
+ }));
374
+ } catch {}
322
375
  }
323
376
  /** Select a task (open detail). */
324
377
  select(id) {
@@ -349,17 +402,45 @@ window.__ModuleLoader__.load({
349
402
  toggleSecondary() {
350
403
  this.setState({ secondaryOpen: !this.state.secondaryOpen });
351
404
  }
352
- /** Create a task (composer submit). */
405
+ /**
406
+ * Install the session-jump bridge (built from the runtime sessions service
407
+ * by the client entry). Without it openSession reports 'unavailable'.
408
+ * @param jumper - the jump function from createSessionJumper.
409
+ */
410
+ installSessionJumper(jumper) {
411
+ this.sessionJumper = jumper;
412
+ }
413
+ /**
414
+ * Jump to an execution's session (open it in the GUI). On success the board
415
+ * closes so the conversation shows; a deleted-or-archived session reports
416
+ * 'missing' for the caller to prompt about.
417
+ * @param sessionId - the execution's session id.
418
+ * @returns the jump outcome.
419
+ */
420
+ async openSession(sessionId) {
421
+ if (this.sessionJumper === void 0) return "unavailable";
422
+ let result;
423
+ try {
424
+ result = await this.sessionJumper(sessionId);
425
+ } catch {
426
+ return "unavailable";
427
+ }
428
+ if (result === "opened") this.closeBoard();
429
+ return result;
430
+ }
431
+ /** Create a task (composer submit); returns the new task id, undefined on failure. */
353
432
  async create(body) {
354
433
  try {
355
- await this.client.create(body);
434
+ const summary = await this.client.create(body);
356
435
  this.setState({
357
436
  composerOpen: false,
358
437
  error: void 0
359
438
  });
360
439
  await this.refresh();
440
+ return summary.id;
361
441
  } catch (error) {
362
442
  this.setState({ error: error instanceof Error ? error.message : String(error) });
443
+ return;
363
444
  }
364
445
  }
365
446
  /** Edit task fields (form modal submit; the GUI is the owner surface). */
@@ -375,8 +456,10 @@ window.__ModuleLoader__.load({
375
456
  error: void 0
376
457
  });
377
458
  await this.refresh();
459
+ return true;
378
460
  } catch (error) {
379
461
  this.setState({ error: error instanceof Error ? error.message : String(error) });
462
+ return false;
380
463
  }
381
464
  }
382
465
  /** Move a task (user surface: done allowed). */
@@ -421,6 +504,15 @@ window.__ModuleLoader__.load({
421
504
  this.setState({ error: error instanceof Error ? error.message : String(error) });
422
505
  }
423
506
  }
507
+ /** Cancel the running execution (stops the agent session; task returns to todo). */
508
+ async cancel(id) {
509
+ try {
510
+ await this.client.cancel(id);
511
+ await this.refresh();
512
+ } catch (error) {
513
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
514
+ }
515
+ }
424
516
  /** Soft-delete (agent parity) then optional purge. */
425
517
  async remove(id, ifVersion, purge) {
426
518
  try {
@@ -431,6 +523,93 @@ window.__ModuleLoader__.load({
431
523
  this.setState({ error: error instanceof Error ? error.message : String(error) });
432
524
  }
433
525
  }
526
+ /** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model). */
527
+ async duplicate(task) {
528
+ try {
529
+ await this.client.create({
530
+ title: `${task.title}(副本)`,
531
+ workspaceId: task.workspaceId,
532
+ urgency: task.urgency,
533
+ description: task.description.length > 0 ? task.description : void 0,
534
+ prompt: task.prompt.length > 0 ? task.prompt : void 0,
535
+ execution: task.execution.mode === "scheduled" && task.execution.cron !== void 0 ? {
536
+ mode: "scheduled",
537
+ cron: task.execution.cron
538
+ } : { mode: "claim" },
539
+ model: task.model
540
+ });
541
+ await this.refresh();
542
+ } catch (error) {
543
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
544
+ }
545
+ }
546
+ /** Download the whole ledger as a JSON backup file. */
547
+ exportJson() {
548
+ const stamp = /* @__PURE__ */ new Date();
549
+ const pad = (n) => String(n).padStart(2, "0");
550
+ const name = `dsh-taskboard-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.json`;
551
+ const body = JSON.stringify(this.state.ledger, null, 2);
552
+ this.download(name, body, "application/json");
553
+ }
554
+ /** Download the task list as a CSV (BOM-prefixed for Excel + Chinese text). */
555
+ exportCsv() {
556
+ const esc = (v) => {
557
+ const s = String(v ?? "");
558
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
559
+ };
560
+ const header = [
561
+ "id",
562
+ "title",
563
+ "status",
564
+ "urgency",
565
+ "blocked",
566
+ "project",
567
+ "claimedBy",
568
+ "mode",
569
+ "cron",
570
+ "nextRunAt",
571
+ "model",
572
+ "createdAt",
573
+ "updatedAt",
574
+ "comments",
575
+ "executions"
576
+ ];
577
+ const rows = this.state.ledger.tasks.map((t) => [
578
+ t.id,
579
+ t.title,
580
+ t.status,
581
+ t.urgency,
582
+ t.blocked ? "yes" : "no",
583
+ t.workspaceId,
584
+ t.claimedBy ?? "",
585
+ t.execution.mode,
586
+ t.execution.cron ?? "",
587
+ t.execution.nextRunAt !== void 0 ? new Date(t.execution.nextRunAt).toISOString() : "",
588
+ t.model !== void 0 ? `${t.model.provider}/${t.model.model}` : "",
589
+ new Date(t.createdAt).toISOString(),
590
+ new Date(t.updatedAt).toISOString(),
591
+ t.comments.length,
592
+ t.executions.length
593
+ ].map(esc).join(","));
594
+ const stamp = /* @__PURE__ */ new Date();
595
+ const pad = (n) => String(n).padStart(2, "0");
596
+ const name = `dsh-taskboard-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}.csv`;
597
+ this.download(name, `\uFEFF${[header.join(","), ...rows].join("\r\n")}`, "text/csv");
598
+ }
599
+ /** Trigger a browser download (no-op when the DOM is unavailable). */
600
+ download(filename, body, type) {
601
+ try {
602
+ const blob = new Blob([body], { type });
603
+ const url = URL.createObjectURL(blob);
604
+ const a = document.createElement("a");
605
+ a.href = url;
606
+ a.download = filename;
607
+ a.click();
608
+ setTimeout(() => URL.revokeObjectURL(url), 5e3);
609
+ } catch (error) {
610
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
611
+ }
612
+ }
434
613
  };
435
614
 
436
615
  //#endregion
@@ -446,7 +625,7 @@ window.__ModuleLoader__.load({
446
625
  /** The stylesheet text. */
447
626
  const STYLES = `
448
627
  .dsh-atb-entry {
449
- display: flex; align-items: center; gap: 8px;
628
+ display: flex; align-items: center; gap: 8px; position: relative;
450
629
  width: calc(100% - 8px); margin: 2px 4px; padding: 6px 10px;
451
630
  border: none; border-radius: 8px; background: transparent;
452
631
  color: var(--dsw-text-secondary, inherit); font: inherit; font-size: 13px;
@@ -455,6 +634,35 @@ window.__ModuleLoader__.load({
455
634
  .dsh-atb-entry:hover { background: var(--dsw-hover, rgba(128,128,128,.12)); color: var(--dsw-text-primary, inherit); }
456
635
  .dsh-atb-entry[data-active="true"] { background: var(--dsw-active, rgba(128,128,128,.18)); color: var(--dsw-text-primary, inherit); font-weight: 500; }
457
636
  .dsh-atb-entry svg { flex: none; }
637
+ /* Status strip on the entry row's right: todo|in_progress|in_review counts. */
638
+ .dsh-atb-entry-stats {
639
+ margin-left: auto; display: inline-flex; align-items: center; gap: 3px;
640
+ font-size: 11px; line-height: 1; color: var(--dsw-text-secondary, gray);
641
+ font-variant-numeric: tabular-nums; white-space: nowrap; cursor: help;
642
+ }
643
+ .dsh-atb-entry-sep { opacity: .5; }
644
+ /* Each rolling count wears its status color (todo blue | in_progress orange |
645
+ in_review purple); the separators stay in the strip's neutral gray. */
646
+ .dsh-atb-roll[data-stat="todo"] { color: #3e63dd; }
647
+ .dsh-atb-roll[data-stat="in_progress"] { color: #d9822b; }
648
+ .dsh-atb-roll[data-stat="in_review"] { color: #8e4ec6; }
649
+ /* One rolling number: fixed one-line window, overflow hidden. */
650
+ .dsh-atb-roll {
651
+ position: relative; display: inline-block; overflow: hidden;
652
+ height: 12px; min-width: 1ch; text-align: center; vertical-align: middle;
653
+ }
654
+ .dsh-atb-rn { display: block; height: 12px; line-height: 12px; text-align: center; }
655
+ /* The incoming value sits just outside the window (below for up-scroll). */
656
+ .dsh-atb-rn-next { position: absolute; left: 0; right: 0; top: 100%; }
657
+ .dsh-atb-roll[data-dir="down"] .dsh-atb-rn-next { top: auto; bottom: 100%; }
658
+ .dsh-atb-roll .dsh-atb-rn { transition: transform .3s cubic-bezier(.25, .1, .25, 1); }
659
+ .dsh-atb-roll[data-anim="1"][data-dir="up"] .dsh-atb-rn { transform: translateY(-100%); }
660
+ .dsh-atb-roll[data-anim="1"][data-dir="down"] .dsh-atb-rn { transform: translateY(100%); }
661
+ @media (prefers-reduced-motion: reduce) {
662
+ .dsh-atb-roll .dsh-atb-rn { transition: none; }
663
+ }
664
+ .dsh-atb-search { width: 130px; }
665
+ .dsh-atb-badge[data-kind="stale"] { background: rgba(217,130,43,.15); color: #d9822b; }
458
666
 
459
667
  html[data-dsh-atb-active] [data-pane="conversation"] > *:not([data-dsh-atb-view]) { display: none !important; }
460
668
  .dsh-atb-view { display: none; }
@@ -464,6 +672,16 @@ window.__ModuleLoader__.load({
464
672
  .dsh-atb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
465
673
  .dsh-atb-title { font-size: 15px; font-weight: 600; margin: 0; }
466
674
  .dsh-atb-count { font-size: 12px; color: var(--dsw-text-secondary, gray); }
675
+ .dsh-atb-ver {
676
+ font-size: 11px; color: var(--dsw-text-secondary, gray);
677
+ font-variant-numeric: tabular-nums; white-space: nowrap; cursor: pointer;
678
+ text-decoration: none;
679
+ padding: 1px 9px; border-radius: 999px;
680
+ background: var(--dsw-bg-inset, rgba(128,128,128,.1));
681
+ border: 1px solid var(--dsw-border, rgba(128,128,128,.22));
682
+ transition: border-color .12s ease, color .12s ease;
683
+ }
684
+ .dsh-atb-ver:hover { border-color: var(--dsw-border-strong, rgba(128,128,128,.6)); color: inherit; }
467
685
  .dsh-atb-spacer { flex: 1; }
468
686
  .dsh-atb-select, .dsh-atb-input {
469
687
  font: inherit; font-size: 12.5px; padding: 5px 8px; border-radius: 7px;
@@ -484,6 +702,16 @@ window.__ModuleLoader__.load({
484
702
  .dsh-atb-dot[data-urgency="urgent"] { background: #e5484d; }
485
703
  .dsh-atb-dot[data-urgency="normal"] { background: #8e4ec6; }
486
704
  .dsh-atb-dot[data-urgency="relaxed"] { background: #3e63dd; }
705
+ /* Status dots (column heads): one fixed color per lifecycle status, matching
706
+ the detail pane's status pills. Canceled/archived share the resting gray;
707
+ trashed (pending purge) keeps the red of the 待清除 badge. */
708
+ .dsh-atb-dot[data-status="backlog"] { background: #8a8f98; }
709
+ .dsh-atb-dot[data-status="todo"] { background: #3e63dd; }
710
+ .dsh-atb-dot[data-status="in_progress"] { background: #d9822b; }
711
+ .dsh-atb-dot[data-status="in_review"] { background: #8e4ec6; }
712
+ .dsh-atb-dot[data-status="done"] { background: #2ea043; }
713
+ .dsh-atb-dot[data-status="canceled"], .dsh-atb-dot[data-status="archived"] { background: #8a8f98; }
714
+ .dsh-atb-dot[data-status="trashed"] { background: #e5484d; }
487
715
 
488
716
  .dsh-atb-btn {
489
717
  font: inherit; font-size: 12.5px; padding: 5px 11px; border-radius: 7px; cursor: pointer;
@@ -608,12 +836,13 @@ window.__ModuleLoader__.load({
608
836
  .dsh-atb-desc { white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.55; }
609
837
 
610
838
  .dsh-atb-detail-actions { display: flex; flex-direction: column; gap: 8px; }
611
- .dsh-atb-runbtn {
612
- font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border-radius: 9px; cursor: pointer;
613
- border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff); text-align: center;
839
+ .dsh-atb-detail-run {
840
+ font: inherit; font-size: 12px; font-weight: 600; padding: 4px 11px; border-radius: 7px; cursor: pointer;
841
+ border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
614
842
  transition: filter .12s ease;
615
843
  }
616
- .dsh-atb-runbtn:hover { filter: brightness(1.1); }
844
+ .dsh-atb-detail-run:hover { filter: brightness(1.1); }
845
+ .dsh-atb-detail-run[data-danger="true"] { background: rgba(229,72,77,.92); }
617
846
  .dsh-atb-movebtns { display: flex; gap: 6px; flex-wrap: wrap; }
618
847
  .dsh-atb-movebtn {
619
848
  font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; cursor: pointer;
@@ -692,7 +921,11 @@ window.__ModuleLoader__.load({
692
921
  .dsh-atb-exec-outcome[data-outcome="running"] { background: rgba(217,130,43,.15); color: #d9822b; }
693
922
  .dsh-atb-exec-outcome[data-outcome="cancelled"] { background: rgba(128,128,128,.15); color: var(--dsw-text-secondary, gray); }
694
923
  .dsh-atb-exec-time { font-size: 11px; color: var(--dsw-text-secondary, gray); }
695
- .dsh-atb-exec-session { font-size: 11px; color: var(--dsw-text-secondary, gray); }
924
+ .dsh-atb-exec-session {
925
+ font: inherit; font-size: 11px; color: var(--dsw-text-secondary, gray);
926
+ background: none; border: none; padding: 0; cursor: pointer;
927
+ }
928
+ .dsh-atb-exec-session:hover { color: var(--dsw-alias-brand-primary, inherit); text-decoration: underline dotted; }
696
929
  .dsh-atb-exec-error { flex-basis: 100%; font-size: 11px; color: #e5484d; word-break: break-all; }
697
930
 
698
931
  .dsh-atb-dangerzone {
@@ -812,6 +1045,29 @@ window.__ModuleLoader__.load({
812
1045
  .dsh-atb-secondary { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
813
1046
  .dsh-atb-link { color: var(--dsw-alias-state-business-primary, #3e63dd); cursor: pointer; text-decoration: none; }
814
1047
  .dsh-atb-link:hover { text-decoration: underline; }
1048
+
1049
+ /* ---------- alert modal ---------- */
1050
+ .dsh-atb-alert-backdrop {
1051
+ position: fixed; inset: 0; z-index: 90;
1052
+ background: var(--dsw-alias-bg-mask-drop, rgba(28,30,36,.4)); backdrop-filter: var(--dsw-mask-blur, blur(2px));
1053
+ display: flex; align-items: center; justify-content: center;
1054
+ animation: dsh-atb-fade .12s ease;
1055
+ }
1056
+ .dsh-atb-alert {
1057
+ min-width: 280px; max-width: 380px; padding: 20px 24px; border-radius: 14px;
1058
+ background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
1059
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
1060
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.18));
1061
+ display: flex; flex-direction: column; align-items: center; gap: 14px;
1062
+ animation: dsh-atb-pop .14s ease;
1063
+ }
1064
+ .dsh-atb-alert-icon { font-size: 28px; line-height: 1; }
1065
+ .dsh-atb-alert-msg {
1066
+ font-size: 13.5px; line-height: 1.55; text-align: center;
1067
+ word-break: break-word; white-space: pre-wrap;
1068
+ color: var(--dsw-alias-label-primary, inherit);
1069
+ }
1070
+ .dsh-atb-alert .dsh-atb-btn { padding: 6px 28px; font-size: 13px; }
815
1071
  `;
816
1072
  let injected = false;
817
1073
  /** Inject the stylesheet once (idempotent). */
@@ -857,12 +1113,108 @@ window.__ModuleLoader__.load({
857
1113
  entry.dataset.dshAtbEntry = "";
858
1114
  entry.className = "dsh-atb-entry";
859
1115
  entry.setAttribute("aria-label", "Agent 任务看板");
860
- entry.innerHTML = `<span class="dsh-atb-entry-icon">${ICON}</span><span class="dsh-atb-entry-label">任务看板</span>`;
1116
+ entry.innerHTML = `<span class="dsh-atb-entry-icon">${ICON}</span><span class="dsh-atb-entry-label">任务看板</span><span class="dsh-atb-entry-stats"></span>`;
861
1117
  entry.addEventListener("click", () => {
862
1118
  controller.toggleBoard();
863
1119
  });
864
1120
  return entry;
865
1121
  }
1122
+ /**
1123
+ * Live status counts shown at the right of the entry row:
1124
+ * `[todo, in_progress, in_review]` (trashed tasks excluded).
1125
+ */
1126
+ function entryStats(controller) {
1127
+ let todo = 0;
1128
+ let inProgress = 0;
1129
+ let inReview = 0;
1130
+ for (const task of controller.getSnapshot().ledger.tasks) {
1131
+ if (task.trashedAt !== void 0) continue;
1132
+ if (task.status === "todo") todo++;
1133
+ else if (task.status === "in_progress") inProgress++;
1134
+ else if (task.status === "in_review") inReview++;
1135
+ }
1136
+ return [
1137
+ todo,
1138
+ inProgress,
1139
+ inReview
1140
+ ];
1141
+ }
1142
+ /**
1143
+ * Set one rolling-number slot. Unchanged values no-op; changes animate the
1144
+ * old value out and the new value in with a vertical scroll (up when the
1145
+ * count grows, down when it shrinks). Plain DOM, no React.
1146
+ */
1147
+ function setRollValue(slot, value) {
1148
+ const text = String(value);
1149
+ if (slot.dataset.value === text) return;
1150
+ const previous = slot.dataset.value;
1151
+ slot.dataset.value = text;
1152
+ slot.style.minWidth = `${text.length}ch`;
1153
+ if (previous === void 0) {
1154
+ slot.textContent = text;
1155
+ return;
1156
+ }
1157
+ if (slot.dataset.busy === "1") {
1158
+ slot.dataset.busy = "";
1159
+ slot.dataset.anim = "";
1160
+ }
1161
+ const oldEl = document.createElement("span");
1162
+ oldEl.className = "dsh-atb-rn";
1163
+ oldEl.textContent = previous;
1164
+ const newEl = document.createElement("span");
1165
+ newEl.className = "dsh-atb-rn dsh-atb-rn-next";
1166
+ newEl.textContent = text;
1167
+ slot.replaceChildren(oldEl, newEl);
1168
+ slot.dataset.dir = value > Number(previous) ? "up" : "down";
1169
+ slot.dataset.busy = "1";
1170
+ requestAnimationFrame(() => {
1171
+ slot.dataset.anim = "1";
1172
+ });
1173
+ const finish = () => {
1174
+ if (slot.dataset.busy !== "1") return;
1175
+ slot.dataset.busy = "";
1176
+ slot.dataset.anim = "";
1177
+ slot.textContent = slot.dataset.value ?? "";
1178
+ };
1179
+ slot.addEventListener("transitionend", finish, { once: true });
1180
+ setTimeout(finish, 400);
1181
+ }
1182
+ /**
1183
+ * Wire the stats strip into the entry: builds the three slots and keeps them
1184
+ * (plus the tooltip) in sync with every controller emit.
1185
+ * @returns the update function (also called once immediately).
1186
+ */
1187
+ function wireStats(entry, controller) {
1188
+ const stats = entry.querySelector(".dsh-atb-entry-stats");
1189
+ if (stats === null) return () => {};
1190
+ const statKeys = [
1191
+ "todo",
1192
+ "in_progress",
1193
+ "in_review"
1194
+ ];
1195
+ const slots = [];
1196
+ for (let i = 0; i < 3; i++) {
1197
+ if (i > 0) {
1198
+ const sep = document.createElement("span");
1199
+ sep.className = "dsh-atb-entry-sep";
1200
+ sep.textContent = "|";
1201
+ stats.append(sep);
1202
+ }
1203
+ const slot = document.createElement("span");
1204
+ slot.className = "dsh-atb-roll";
1205
+ slot.dataset.stat = statKeys[i];
1206
+ stats.append(slot);
1207
+ slots.push(slot);
1208
+ }
1209
+ const update = () => {
1210
+ const [todo, inProgress, inReview] = entryStats(controller);
1211
+ setRollValue(slots[0], todo);
1212
+ setRollValue(slots[1], inProgress);
1213
+ setRollValue(slots[2], inReview);
1214
+ stats.title = `待办 ${todo} | 进行中 ${inProgress} | 待验收 ${inReview}(待办|进行中|待验收)`;
1215
+ };
1216
+ return update;
1217
+ }
866
1218
  /** Re-insert the entry after the New Session row (before the browser region). */
867
1219
  function placeEntry(root, entry) {
868
1220
  const button = newSessionButton(root);
@@ -933,9 +1285,11 @@ window.__ModuleLoader__.load({
933
1285
  const retry = setInterval(() => {
934
1286
  tryPlace();
935
1287
  }, 2e3);
1288
+ const syncStats = wireStats(entry, controller);
936
1289
  const syncActive = () => {
937
1290
  if (controller.getSnapshot().boardOpen) entry.dataset.active = "true";
938
1291
  else delete entry.dataset.active;
1292
+ syncStats();
939
1293
  };
940
1294
  const unsubscribe = controller.subscribe(syncActive);
941
1295
  syncActive();
@@ -949,6 +1303,17 @@ window.__ModuleLoader__.load({
949
1303
  };
950
1304
  }
951
1305
 
1306
+ //#endregion
1307
+ //#region src/shared/version.ts
1308
+ /**
1309
+ * The plugin package version shown in the board UI. Kept in sync with
1310
+ * package.json by a regression test (tests lock drift).
1311
+ *
1312
+ * @module dsh-taskboard/shared/version
1313
+ */
1314
+ /** The package version (must equal package.json "version"). */
1315
+ const PLUGIN_VERSION = "0.2.0";
1316
+
952
1317
  //#endregion
953
1318
  //#region src/client/board/TaskCard.tsx
954
1319
  const URGENCY_LABEL$1 = {
@@ -968,16 +1333,27 @@ window.__ModuleLoader__.load({
968
1333
  * The card view.
969
1334
  * @param task - the task record.
970
1335
  * @param controller - the controller.
971
- * @param draggable - enable dragging (backlog/todo columns only).
1336
+ * @param draggable - enable dragging.
1337
+ * @param now - current epoch ms (stale-claim highlight).
1338
+ * @param onAlert - show an alert message (replaces native alert).
972
1339
  */
973
- function TaskCard({ task, controller, draggable = false }) {
1340
+ function TaskCard({ task, controller, draggable = false, now, onAlert }) {
974
1341
  const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : void 0;
1342
+ const running = task.executions.find((ex) => ex.outcome === "running");
1343
+ const stale = now !== void 0 && isStaleClaim(task, now);
975
1344
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
976
1345
  type: "button",
977
1346
  className: "dsh-atb-card",
978
1347
  "data-urgency": task.urgency,
979
1348
  draggable,
980
1349
  onDragStart: (e) => {
1350
+ if (running !== void 0) {
1351
+ e.preventDefault();
1352
+ const msg = `该任务正在由【${task.title}】会话执行,不能拖动`;
1353
+ if (onAlert !== void 0) onAlert(msg);
1354
+ else alert(msg);
1355
+ return;
1356
+ }
981
1357
  e.dataTransfer.setData(DRAG_TYPE, task.id);
982
1358
  e.dataTransfer.effectAllowed = "move";
983
1359
  e.currentTarget.dataset.dragging = "true";
@@ -1001,6 +1377,11 @@ window.__ModuleLoader__.load({
1001
1377
  "data-kind": "blocked",
1002
1378
  children: "受阻"
1003
1379
  }),
1380
+ stale && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1381
+ className: "dsh-atb-badge",
1382
+ "data-kind": "stale",
1383
+ children: "⏱ 认领超时"
1384
+ }),
1004
1385
  task.execution.mode === "scheduled" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1005
1386
  className: "dsh-atb-badge",
1006
1387
  "data-kind": "scheduled",
@@ -1035,6 +1416,62 @@ window.__ModuleLoader__.load({
1035
1416
  });
1036
1417
  }
1037
1418
 
1419
+ //#endregion
1420
+ //#region src/client/board/AlertModal.tsx
1421
+ /**
1422
+ * A lightweight alert modal — replaces native alert() with a themed overlay
1423
+ * that matches the shell design tokens.
1424
+ *
1425
+ * @module dsh-taskboard/client/board/AlertModal
1426
+ */
1427
+ /** Show a non-blocking alert modal. Returns true when opened. */
1428
+ function useAlert() {
1429
+ const [msg, setMsg] = (0, react.useState)(null);
1430
+ const show = (m) => setMsg(m);
1431
+ const close = () => setMsg(null);
1432
+ return {
1433
+ alert: show,
1434
+ el: msg !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AlertModal, {
1435
+ message: msg,
1436
+ onClose: close
1437
+ }) : null
1438
+ };
1439
+ }
1440
+ function AlertModal({ message, onClose }) {
1441
+ (0, react.useEffect)(() => {
1442
+ const handler = (e) => {
1443
+ if (e.key === "Escape") onClose();
1444
+ };
1445
+ window.addEventListener("keydown", handler);
1446
+ return () => window.removeEventListener("keydown", handler);
1447
+ }, [onClose]);
1448
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1449
+ className: "dsh-atb-alert-backdrop",
1450
+ onClick: onClose,
1451
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1452
+ className: "dsh-atb-alert",
1453
+ onClick: (e) => e.stopPropagation(),
1454
+ children: [
1455
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1456
+ className: "dsh-atb-alert-icon",
1457
+ children: "⛔"
1458
+ }),
1459
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1460
+ className: "dsh-atb-alert-msg",
1461
+ children: message
1462
+ }),
1463
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1464
+ type: "button",
1465
+ className: "dsh-atb-btn",
1466
+ "data-primary": "true",
1467
+ onClick: onClose,
1468
+ children: "知道了"
1469
+ })
1470
+ ]
1471
+ })
1472
+ });
1473
+ }
1474
+
1038
1475
  //#endregion
1039
1476
  //#region src/client/board/TaskDetail.tsx
1040
1477
  /**
@@ -1079,10 +1516,10 @@ window.__ModuleLoader__.load({
1079
1516
  failed: "失败",
1080
1517
  cancelled: "已取消"
1081
1518
  };
1082
- /** Compact session-id display. */
1519
+ /** Compact session-id display (execution sessions carry the taskboard infix). */
1083
1520
  function shortId(id) {
1084
1521
  if (id === void 0) return "";
1085
- return id.replace(/^session-/, "").slice(0, 8);
1522
+ return id.replace(/^session-(taskboard-)?/, "").slice(0, 8);
1086
1523
  }
1087
1524
  /** Execution duration between start and end. */
1088
1525
  function duration(startedAt, endedAt) {
@@ -1107,13 +1544,27 @@ window.__ModuleLoader__.load({
1107
1544
  * The detail view.
1108
1545
  * @param task - the task record.
1109
1546
  * @param controller - the controller.
1547
+ * @param now - current epoch ms (stale-claim highlight).
1110
1548
  */
1111
- function TaskDetail({ task, controller }) {
1549
+ function TaskDetail({ task, controller, now }) {
1112
1550
  const [comment, setComment] = (0, react.useState)("");
1113
1551
  const [confirmDone, setConfirmDone] = (0, react.useState)(false);
1114
1552
  const [confirmPurge, setConfirmPurge] = (0, react.useState)(false);
1553
+ const [confirmCancel, setConfirmCancel] = (0, react.useState)(false);
1554
+ const { alert: showAlert, el: alertEl } = useAlert();
1115
1555
  const ws = controller.getSnapshot().workspaces.find((w) => w.id === task.workspaceId);
1116
1556
  const canRun = task.status !== "in_progress" && task.status !== "done" && task.status !== "archived";
1557
+ const runningExecution = task.executions.find((e) => e.outcome === "running");
1558
+ const holder = task.status === "in_progress" ? task.claimedBy : void 0;
1559
+ const stale = now !== void 0 && isStaleClaim(task, now);
1560
+ /** Jump to an execution's session; prompt precisely when it cannot open. */
1561
+ const jumpToSession = (sessionId) => {
1562
+ controller.openSession(sessionId).then((result) => {
1563
+ if (result === "missing") showAlert(`该会话已被删除(${shortId(sessionId)}),无法打开`);
1564
+ else if (result === "archived") showAlert(`该会话已归档(${shortId(sessionId)}),已从会话列表隐藏`);
1565
+ else if (result === "unavailable") showAlert(`会话导航不可用,会话 ID:${sessionId}`);
1566
+ });
1567
+ };
1117
1568
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1118
1569
  className: "dsh-atb-detail",
1119
1570
  "data-urgency": task.urgency,
@@ -1159,6 +1610,15 @@ window.__ModuleLoader__.load({
1159
1610
  tone: "urgent",
1160
1611
  children: "受阻"
1161
1612
  }),
1613
+ holder !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Chip, {
1614
+ icon: stale ? "⏱" : "🔑",
1615
+ tone: stale ? "urgent" : void 0,
1616
+ children: [
1617
+ stale ? "认领超时 · " : "由 ",
1618
+ shortId(holder),
1619
+ " 持有"
1620
+ ]
1621
+ }),
1162
1622
  task.trashedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Chip, {
1163
1623
  icon: "🗑",
1164
1624
  tone: "urgent",
@@ -1179,18 +1639,67 @@ window.__ModuleLoader__.load({
1179
1639
  ]
1180
1640
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1181
1641
  className: "dsh-atb-detail-topbtns",
1182
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1183
- type: "button",
1184
- className: "dsh-atb-detail-edit",
1185
- onClick: () => controller.openEditor(task.id),
1186
- children: "✎ 编辑"
1187
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1188
- type: "button",
1189
- className: "dsh-atb-detail-close",
1190
- "aria-label": "关闭",
1191
- onClick: () => controller.select(void 0),
1192
- children: ""
1193
- })]
1642
+ children: [
1643
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1644
+ type: "button",
1645
+ className: "dsh-atb-detail-edit",
1646
+ onClick: () => controller.openEditor(task.id),
1647
+ children: "✎ 编辑"
1648
+ }),
1649
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1650
+ type: "button",
1651
+ className: "dsh-atb-detail-edit",
1652
+ title: "复制此任务的全部配置为一张新卡(待办列)",
1653
+ onClick: () => void controller.duplicate(task),
1654
+ children: "⧉ 复制"
1655
+ }),
1656
+ canRun && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1657
+ type: "button",
1658
+ className: "dsh-atb-detail-run",
1659
+ title: task.model !== void 0 ? `新会话执行(${task.model.model})` : "新会话执行(默认模型)",
1660
+ onClick: () => void controller.run(task.id),
1661
+ children: "▶ 立即执行"
1662
+ }),
1663
+ runningExecution !== void 0 && (confirmCancel ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1664
+ className: "dsh-atb-confirm",
1665
+ children: [
1666
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1667
+ className: "dsh-atb-confirm-label",
1668
+ children: "停止该执行会话?"
1669
+ }),
1670
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1671
+ type: "button",
1672
+ className: "dsh-atb-btn",
1673
+ "data-danger": "true",
1674
+ onClick: () => {
1675
+ controller.cancel(task.id);
1676
+ setConfirmCancel(false);
1677
+ },
1678
+ children: "停止"
1679
+ }),
1680
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1681
+ type: "button",
1682
+ className: "dsh-atb-btn",
1683
+ onClick: () => setConfirmCancel(false),
1684
+ children: "取消"
1685
+ })
1686
+ ]
1687
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1688
+ type: "button",
1689
+ className: "dsh-atb-detail-run",
1690
+ "data-danger": "true",
1691
+ title: `停止执行会话 ${runningExecution.sessionId ?? ""}(任务回到待办)`,
1692
+ onClick: () => setConfirmCancel(true),
1693
+ children: "■ 停止执行"
1694
+ })),
1695
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1696
+ type: "button",
1697
+ className: "dsh-atb-detail-close",
1698
+ "aria-label": "关闭",
1699
+ onClick: () => controller.select(void 0),
1700
+ children: "✕"
1701
+ })
1702
+ ]
1194
1703
  })]
1195
1704
  }),
1196
1705
  task.description.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1214,59 +1723,65 @@ window.__ModuleLoader__.load({
1214
1723
  children: task.prompt
1215
1724
  })]
1216
1725
  }),
1217
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1726
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1218
1727
  className: "dsh-atb-detail-actions",
1219
- children: [canRun && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1220
- type: "button",
1221
- className: "dsh-atb-runbtn",
1222
- onClick: () => void controller.run(task.id),
1223
- children: ["▶ 执行 · 新会话", task.model !== void 0 ? `(${task.model.model})` : "(默认模型)"]
1224
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1728
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1225
1729
  className: "dsh-atb-movebtns",
1226
- children: [moveTargets(task).map((to) => to === "done" ? confirmDone ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1227
- className: "dsh-atb-confirm",
1228
- children: [
1229
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1230
- className: "dsh-atb-confirm-label",
1231
- children: "确认完成?"
1232
- }),
1233
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1234
- type: "button",
1235
- className: "dsh-atb-btn",
1236
- "data-primary": "true",
1237
- onClick: () => {
1238
- controller.move(task.id, task.version, "done");
1239
- setConfirmDone(false);
1240
- },
1241
- children: "确认"
1242
- }),
1243
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1244
- type: "button",
1245
- className: "dsh-atb-btn",
1246
- onClick: () => setConfirmDone(false),
1247
- children: "取消"
1248
- })
1249
- ]
1250
- }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1251
- type: "button",
1252
- className: "dsh-atb-movebtn",
1253
- "data-to": to,
1254
- onClick: () => setConfirmDone(true),
1255
- children: ["✓ ", MOVE_LABEL[to]]
1256
- }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1257
- type: "button",
1258
- className: "dsh-atb-movebtn",
1259
- "data-to": to,
1260
- onClick: () => void controller.move(task.id, task.version, to),
1261
- children: MOVE_LABEL[to]
1262
- }, to)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1263
- type: "button",
1264
- className: "dsh-atb-movebtn",
1265
- "data-to": "blocked",
1266
- onClick: () => void controller.toggleBlocked(task),
1267
- children: task.blocked ? "✓ 解除受阻" : "⛔ 标记受阻"
1268
- })]
1269
- })]
1730
+ children: [
1731
+ moveTargets(task).map((to) => to === "done" ? confirmDone ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1732
+ className: "dsh-atb-confirm",
1733
+ children: [
1734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1735
+ className: "dsh-atb-confirm-label",
1736
+ children: "确认完成?"
1737
+ }),
1738
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1739
+ type: "button",
1740
+ className: "dsh-atb-btn",
1741
+ "data-primary": "true",
1742
+ onClick: () => {
1743
+ controller.move(task.id, task.version, "done");
1744
+ setConfirmDone(false);
1745
+ },
1746
+ children: "确认"
1747
+ }),
1748
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1749
+ type: "button",
1750
+ className: "dsh-atb-btn",
1751
+ onClick: () => setConfirmDone(false),
1752
+ children: "取消"
1753
+ })
1754
+ ]
1755
+ }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1756
+ type: "button",
1757
+ className: "dsh-atb-movebtn",
1758
+ "data-to": to,
1759
+ onClick: () => setConfirmDone(true),
1760
+ children: ["移至→", MOVE_LABEL[to]]
1761
+ }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1762
+ type: "button",
1763
+ className: "dsh-atb-movebtn",
1764
+ "data-to": to,
1765
+ onClick: () => void controller.move(task.id, task.version, to),
1766
+ children: ["移至→", MOVE_LABEL[to]]
1767
+ }, to)),
1768
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1769
+ type: "button",
1770
+ className: "dsh-atb-movebtn",
1771
+ "data-to": "blocked",
1772
+ onClick: () => void controller.toggleBlocked(task),
1773
+ children: task.blocked ? "✓ 解除受阻" : "⛔ 标记受阻"
1774
+ }),
1775
+ holder !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1776
+ type: "button",
1777
+ className: "dsh-atb-movebtn",
1778
+ "data-to": "release",
1779
+ title: `释放 ${holder} 的认领:任务回到待办(持有会话可能仍在工作,确认它已停止后再释放)`,
1780
+ onClick: () => void controller.move(task.id, task.version, "todo"),
1781
+ children: "🔓 释放认领"
1782
+ })
1783
+ ]
1784
+ })
1270
1785
  }),
1271
1786
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1272
1787
  className: "dsh-atb-section",
@@ -1326,12 +1841,24 @@ window.__ModuleLoader__.load({
1326
1841
  }),
1327
1842
  task.executions.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1328
1843
  className: "dsh-atb-section",
1329
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h4", { children: ["执行记录", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1330
- className: "dsh-atb-count2",
1331
- children: task.executions.length
1332
- })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1844
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h4", { children: [
1845
+ "执行记录",
1846
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1847
+ className: "dsh-atb-count2",
1848
+ children: task.executions.length
1849
+ }),
1850
+ task.executionsPruned !== void 0 && task.executionsPruned > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1851
+ className: "dsh-atb-count2",
1852
+ title: `更早的 ${task.executionsPruned} 条执行记录已按保留上限清理`,
1853
+ children: [
1854
+ "+",
1855
+ task.executionsPruned,
1856
+ " 已清理"
1857
+ ]
1858
+ })
1859
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1333
1860
  className: "dsh-atb-execlist",
1334
- children: task.executions.map((e) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1861
+ children: [...task.executions].reverse().map((e) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1335
1862
  className: "dsh-atb-exec-row",
1336
1863
  children: [
1337
1864
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1351,10 +1878,16 @@ window.__ModuleLoader__.load({
1351
1878
  className: "dsh-atb-exec-time",
1352
1879
  children: [fmtTime(e.startedAt), e.endedAt !== void 0 && ` · ${duration(e.startedAt, e.endedAt)}`]
1353
1880
  }),
1354
- e.sessionId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1881
+ e.sessionId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1882
+ type: "button",
1355
1883
  className: "dsh-atb-exec-session",
1356
- title: e.sessionId,
1357
- children: ["🤖 ", shortId(e.sessionId)]
1884
+ title: `点击打开该执行会话:${e.sessionId}`,
1885
+ onClick: () => jumpToSession(e.sessionId),
1886
+ children: [
1887
+ "🤖 ",
1888
+ shortId(e.sessionId),
1889
+ " ↗"
1890
+ ]
1358
1891
  }),
1359
1892
  e.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1360
1893
  className: "dsh-atb-exec-error",
@@ -1404,7 +1937,8 @@ window.__ModuleLoader__.load({
1404
1937
  onClick: () => setConfirmPurge(true),
1405
1938
  children: "🔥 物理清除(需确认)"
1406
1939
  })
1407
- })
1940
+ }),
1941
+ alertEl
1408
1942
  ]
1409
1943
  });
1410
1944
  }
@@ -1509,6 +2043,7 @@ window.__ModuleLoader__.load({
1509
2043
  const nextRun = cronMatch !== null ? nextCronTime(cronMatch, Date.now()) : null;
1510
2044
  const cronBad = mode === "scheduled" && (cronMatch === null || nextRun === null);
1511
2045
  const valid = title.trim().length > 0 && workspaceId !== "" && !cronBad;
2046
+ const runBlocked = editing && task.status === "in_progress";
1512
2047
  const submit = () => {
1513
2048
  if (!valid) return;
1514
2049
  const picked = model !== "" ? JSON.parse(model) : void 0;
@@ -1537,6 +2072,40 @@ window.__ModuleLoader__.load({
1537
2072
  model: picked
1538
2073
  });
1539
2074
  };
2075
+ /** Save the form, then immediately trigger a manual run of the task. */
2076
+ const submitAndRun = () => {
2077
+ if (!valid || runBlocked) return;
2078
+ const picked = model !== "" ? JSON.parse(model) : void 0;
2079
+ if (editing) (async () => {
2080
+ if (await controller.update(task.id, task.version, {
2081
+ title,
2082
+ description,
2083
+ prompt,
2084
+ urgency,
2085
+ workspaceId,
2086
+ execution: mode === "scheduled" ? {
2087
+ mode,
2088
+ cron: cron.trim()
2089
+ } : { mode },
2090
+ model: picked ?? null
2091
+ })) await controller.run(task.id);
2092
+ })();
2093
+ else (async () => {
2094
+ const id = await controller.create({
2095
+ title,
2096
+ workspaceId,
2097
+ urgency,
2098
+ description: description.length > 0 ? description : void 0,
2099
+ prompt: prompt.length > 0 ? prompt : void 0,
2100
+ execution: mode === "scheduled" ? {
2101
+ mode,
2102
+ cron: cron.trim()
2103
+ } : { mode },
2104
+ model: picked
2105
+ });
2106
+ if (id !== void 0) await controller.run(id);
2107
+ })();
2108
+ };
1540
2109
  const hint = !valid ? title.trim().length === 0 ? "请填写标题" : workspaceId === "" ? "请选择项目" : "Cron 表达式无效(分 时 日 月 周)" : mode === "scheduled" && nextRun !== null ? `下次运行 ${fmtTime(nextRun)}` : editing ? `保存后版本 v${task.version} → v${task.version + 1}` : "创建后项目内会话可认领执行";
1541
2110
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1542
2111
  className: "dsh-atb-modal-backdrop",
@@ -1658,7 +2227,7 @@ window.__ModuleLoader__.load({
1658
2227
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1659
2228
  value: prompt,
1660
2229
  onChange: (e) => setPrompt(e.target.value),
1661
- placeholder: "发给执行会话的完整指令"
2230
+ placeholder: "发给执行会话的完整指令。支持模板变量:{{lastExecution}}(上次执行结果)、{{lastComments}}(最近 3 条评论)"
1662
2231
  })
1663
2232
  }),
1664
2233
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
@@ -1727,19 +2296,30 @@ window.__ModuleLoader__.load({
1727
2296
  children: hint
1728
2297
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1729
2298
  className: "dsh-atb-modal-footbtns",
1730
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1731
- type: "button",
1732
- className: "dsh-atb-btn",
1733
- onClick: () => controller.closeForm(),
1734
- children: "取消"
1735
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1736
- type: "button",
1737
- className: "dsh-atb-btn",
1738
- "data-primary": "true",
1739
- disabled: !valid,
1740
- onClick: submit,
1741
- children: editing ? "保存修改" : "创建任务"
1742
- })]
2299
+ children: [
2300
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2301
+ type: "button",
2302
+ className: "dsh-atb-btn",
2303
+ onClick: () => controller.closeForm(),
2304
+ children: "取消"
2305
+ }),
2306
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2307
+ type: "button",
2308
+ className: "dsh-atb-btn",
2309
+ disabled: !valid || runBlocked,
2310
+ title: runBlocked ? "任务正在执行中,不能重复发起" : "保存后立即发起执行(新会话)",
2311
+ onClick: submitAndRun,
2312
+ children: "⚡ 立即执行"
2313
+ }),
2314
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2315
+ type: "button",
2316
+ className: "dsh-atb-btn",
2317
+ "data-primary": "true",
2318
+ disabled: !valid,
2319
+ onClick: submit,
2320
+ children: editing ? "保存修改" : "创建任务"
2321
+ })
2322
+ ]
1743
2323
  })]
1744
2324
  })
1745
2325
  ]
@@ -1765,8 +2345,6 @@ window.__ModuleLoader__.load({
1765
2345
  canceled: "已取消",
1766
2346
  archived: "已归档"
1767
2347
  };
1768
- /** The two columns between which cards may be dragged both ways. */
1769
- const DRAGGABLE_STATUSES = /* @__PURE__ */ new Set(["backlog", "todo"]);
1770
2348
  /** Urgency chip labels. */
1771
2349
  const URGENCY_LABELS = {
1772
2350
  urgent: "紧急",
@@ -1780,9 +2358,28 @@ window.__ModuleLoader__.load({
1780
2358
  const pad = (n) => String(n).padStart(2, "0");
1781
2359
  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
1782
2360
  }
1783
- /** Apply the active filters to a task list. */
2361
+ /** A claim idle for longer than this is highlighted as stale (ms). */
2362
+ const STALE_CLAIM_MS = 30 * 6e4;
2363
+ /** Whether the task's claim is stale (in_progress, held, idle too long). */
2364
+ function isStaleClaim(task, now) {
2365
+ return task.status === "in_progress" && task.claimedAt !== void 0 && now - task.claimedAt > 18e5;
2366
+ }
2367
+ /** Urgency sort rank (urgent first). */
2368
+ const URGENCY_RANK = {
2369
+ urgent: 0,
2370
+ normal: 1,
2371
+ relaxed: 2
2372
+ };
2373
+ /** Apply the active filters + search + sort to a task list. */
1784
2374
  function filterTasks(state, tasks) {
1785
- return tasks.filter((t) => (state.filters.workspaceId === void 0 || t.workspaceId === state.filters.workspaceId) && (state.filters.urgencies.length === 0 || state.filters.urgencies.includes(t.urgency)));
2375
+ const q = state.search.trim().toLowerCase();
2376
+ const filtered = tasks.filter((t) => (state.filters.workspaceId === void 0 || t.workspaceId === state.filters.workspaceId) && (state.filters.urgencies.length === 0 || state.filters.urgencies.includes(t.urgency)) && (q.length === 0 || t.title.toLowerCase().includes(q) || t.id.toLowerCase().includes(q)));
2377
+ if (state.sortBy === "default") return filtered;
2378
+ const sorted = [...filtered];
2379
+ if (state.sortBy === "updated") sorted.sort((a, b) => b.updatedAt - a.updatedAt);
2380
+ else if (state.sortBy === "created") sorted.sort((a, b) => b.createdAt - a.createdAt);
2381
+ else if (state.sortBy === "urgency") sorted.sort((a, b) => URGENCY_RANK[a.urgency] - URGENCY_RANK[b.urgency] || b.updatedAt - a.updatedAt);
2382
+ return sorted;
1786
2383
  }
1787
2384
  /**
1788
2385
  * The board view root.
@@ -1790,8 +2387,14 @@ window.__ModuleLoader__.load({
1790
2387
  */
1791
2388
  function TaskBoard({ controller }) {
1792
2389
  const state = (0, react.useSyncExternalStore)((cb) => controller.subscribe(cb), () => controller.getSnapshot());
2390
+ const [now, setNow] = (0, react.useState)(() => Date.now());
2391
+ (0, react.useEffect)(() => {
2392
+ const timer = setInterval(() => setNow(Date.now()), 6e4);
2393
+ return () => clearInterval(timer);
2394
+ }, []);
1793
2395
  const live = filterTasks(state, state.ledger.tasks.filter((t) => t.trashedAt === void 0));
1794
2396
  const selected = state.selectedId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.selectedId);
2397
+ const { alert: showAlert, el: alertEl } = useAlert();
1795
2398
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1796
2399
  className: "dsh-atb-board",
1797
2400
  children: [
@@ -1810,7 +2413,21 @@ window.__ModuleLoader__.load({
1810
2413
  state.ledger.revision
1811
2414
  ]
1812
2415
  }),
2416
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2417
+ type: "button",
2418
+ className: "dsh-atb-btn",
2419
+ "data-primary": "true",
2420
+ onClick: () => controller.setComposer(true),
2421
+ children: "+ 新建任务"
2422
+ }),
1813
2423
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "dsh-atb-spacer" }),
2424
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2425
+ className: "dsh-atb-input dsh-atb-search",
2426
+ value: state.search,
2427
+ placeholder: "搜索标题 / ID…",
2428
+ spellCheck: false,
2429
+ onChange: (e) => controller.setSearch(e.target.value)
2430
+ }),
1814
2431
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1815
2432
  className: "dsh-atb-select",
1816
2433
  value: state.filters.workspaceId ?? "",
@@ -1823,6 +2440,30 @@ window.__ModuleLoader__.load({
1823
2440
  children: ws.title || ws.path
1824
2441
  }, ws.id))]
1825
2442
  }),
2443
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2444
+ className: "dsh-atb-select",
2445
+ value: state.sortBy,
2446
+ title: "列内排序",
2447
+ onChange: (e) => controller.setSortBy(e.target.value),
2448
+ children: [
2449
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2450
+ value: "default",
2451
+ children: "默认排序"
2452
+ }),
2453
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2454
+ value: "updated",
2455
+ children: "最近更新"
2456
+ }),
2457
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2458
+ value: "urgency",
2459
+ children: "按紧急度"
2460
+ }),
2461
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2462
+ value: "created",
2463
+ children: "创建时间"
2464
+ })
2465
+ ]
2466
+ }),
1826
2467
  [
1827
2468
  "urgent",
1828
2469
  "normal",
@@ -1847,9 +2488,23 @@ window.__ModuleLoader__.load({
1847
2488
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1848
2489
  type: "button",
1849
2490
  className: "dsh-atb-btn",
1850
- "data-primary": "true",
1851
- onClick: () => controller.setComposer(true),
1852
- children: "+ 新建任务"
2491
+ title: "下载完整台账备份(JSON)",
2492
+ onClick: () => controller.exportJson(),
2493
+ children: " JSON"
2494
+ }),
2495
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2496
+ type: "button",
2497
+ className: "dsh-atb-btn",
2498
+ title: "下载任务清单(CSV)",
2499
+ onClick: () => controller.exportCsv(),
2500
+ children: "⬇ CSV"
2501
+ }),
2502
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
2503
+ className: "dsh-atb-ver",
2504
+ href: "https://github.com/cloader/dsh-taskboard",
2505
+ target: "_blank",
2506
+ rel: "noopener noreferrer",
2507
+ children: ["V", PLUGIN_VERSION]
1853
2508
  })
1854
2509
  ]
1855
2510
  }),
@@ -1864,40 +2519,52 @@ window.__ModuleLoader__.load({
1864
2519
  className: "dsh-atb-columns",
1865
2520
  children: MAIN_STATUSES.map((status) => {
1866
2521
  const columnTasks = live.filter((t) => t.status === status);
1867
- const dropTarget = DRAGGABLE_STATUSES.has(status);
1868
2522
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1869
2523
  className: "dsh-atb-column",
1870
- onDragOver: dropTarget ? (e) => {
2524
+ onDragOver: (e) => {
1871
2525
  if (e.dataTransfer.types.includes("application/x-dsh-atb-task")) {
1872
2526
  e.preventDefault();
1873
2527
  e.dataTransfer.dropEffect = "move";
1874
2528
  e.currentTarget.dataset.dragover = "true";
1875
2529
  }
1876
- } : void 0,
1877
- onDragLeave: dropTarget ? (e) => {
2530
+ },
2531
+ onDragLeave: (e) => {
1878
2532
  delete e.currentTarget.dataset.dragover;
1879
- } : void 0,
1880
- onDrop: dropTarget ? (e) => {
2533
+ },
2534
+ onDrop: (e) => {
1881
2535
  e.preventDefault();
1882
2536
  delete e.currentTarget.dataset.dragover;
1883
2537
  const id = e.dataTransfer.getData(DRAG_TYPE);
1884
2538
  if (id.length === 0) return;
1885
2539
  const task = state.ledger.tasks.find((t) => t.id === id);
1886
2540
  if (task === void 0 || task.status === status) return;
2541
+ if (!canTransition(task.status, status)) {
2542
+ showAlert(`无法从「${COLUMN_LABELS[task.status]}」拖至「${COLUMN_LABELS[status]}」`);
2543
+ return;
2544
+ }
1887
2545
  controller.move(id, task.version, status);
1888
- } : void 0,
2546
+ },
1889
2547
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1890
2548
  className: "dsh-atb-colhead",
1891
- children: [COLUMN_LABELS[status], /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1892
- className: "dsh-atb-colcount",
1893
- children: columnTasks.length
1894
- })]
2549
+ children: [
2550
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2551
+ className: "dsh-atb-dot",
2552
+ "data-status": status
2553
+ }),
2554
+ COLUMN_LABELS[status],
2555
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2556
+ className: "dsh-atb-colcount",
2557
+ children: columnTasks.length
2558
+ })
2559
+ ]
1895
2560
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1896
2561
  className: "dsh-atb-cards",
1897
2562
  children: [columnTasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
1898
2563
  task,
1899
2564
  controller,
1900
- draggable: dropTarget
2565
+ draggable: true,
2566
+ now,
2567
+ onAlert: showAlert
1901
2568
  }, task.id)), columnTasks.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1902
2569
  className: "dsh-atb-empty",
1903
2570
  children: "无任务"
@@ -1910,32 +2577,75 @@ window.__ModuleLoader__.load({
1910
2577
  className: "dsh-atb-detailpanel",
1911
2578
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskDetail, {
1912
2579
  task: selected,
1913
- controller
2580
+ controller,
2581
+ now
1914
2582
  })
1915
2583
  }),
1916
2584
  state.composerOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskFormModal, {
1917
2585
  controller,
1918
2586
  task: state.editingId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.editingId)
1919
- })
2587
+ }),
2588
+ alertEl
1920
2589
  ]
1921
2590
  });
1922
2591
  }
1923
- /** Secondary tab: canceled/archived/trashed rows. */
2592
+ /** Secondary tab: tasks grouped into canceled / archived / trashed columns. */
1924
2593
  function SecondaryTab({ controller, tasks }) {
1925
- const rows = tasks.filter((t) => t.status === "canceled" || t.status === "archived" || t.trashedAt !== void 0);
1926
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2594
+ const trashed = tasks.filter((t) => t.trashedAt !== void 0);
2595
+ const archived = tasks.filter((t) => t.trashedAt === void 0 && t.status === "archived");
2596
+ const canceled = tasks.filter((t) => t.trashedAt === void 0 && t.status === "canceled");
2597
+ const groups = [
2598
+ {
2599
+ label: "已取消",
2600
+ dot: "canceled",
2601
+ rows: canceled
2602
+ },
2603
+ {
2604
+ label: "已归档",
2605
+ dot: "archived",
2606
+ rows: archived
2607
+ },
2608
+ {
2609
+ label: "已删除",
2610
+ dot: "trashed",
2611
+ rows: trashed
2612
+ }
2613
+ ];
2614
+ if (trashed.length + archived.length + canceled.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1927
2615
  className: "dsh-atb-secondary",
1928
- children: [
1929
- rows.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1930
- className: "dsh-atb-empty",
1931
- children: "无已取消 / 已归档 / 已删除任务"
1932
- }),
1933
- rows.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
1934
- task,
1935
- controller
1936
- }, task.id)),
1937
- void 0
1938
- ]
2616
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2617
+ className: "dsh-atb-empty",
2618
+ children: "无已取消 / 已归档 / 已删除任务"
2619
+ })
2620
+ });
2621
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2622
+ className: "dsh-atb-columns",
2623
+ children: groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2624
+ className: "dsh-atb-column",
2625
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2626
+ className: "dsh-atb-colhead",
2627
+ children: [
2628
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2629
+ className: "dsh-atb-dot",
2630
+ "data-status": group.dot
2631
+ }),
2632
+ group.label,
2633
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2634
+ className: "dsh-atb-colcount",
2635
+ children: group.rows.length
2636
+ })
2637
+ ]
2638
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2639
+ className: "dsh-atb-cards",
2640
+ children: [group.rows.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
2641
+ task,
2642
+ controller
2643
+ }, task.id)), group.rows.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2644
+ className: "dsh-atb-empty",
2645
+ children: "无任务"
2646
+ })]
2647
+ })]
2648
+ }, group.label))
1939
2649
  });
1940
2650
  }
1941
2651
 
@@ -2020,6 +2730,39 @@ window.__ModuleLoader__.load({
2020
2730
  };
2021
2731
  }
2022
2732
 
2733
+ //#endregion
2734
+ //#region src/client/session-jump.ts
2735
+ /**
2736
+ * Build the jump function the controller installs.
2737
+ * @param access - lazy service accessors, consulted on every jump.
2738
+ * @returns the jump function: `(sessionId) => Promise<SessionJumpResult>`.
2739
+ */
2740
+ function createSessionJumper(access) {
2741
+ const lookup = (sessions, workspaces, sessionId) => {
2742
+ if (sessions.list.getSnapshot().byId[sessionId] === void 0) return "absent";
2743
+ return workspaces?.list.getSnapshot().archivedSessionIds.includes(sessionId) ?? false ? "archived" : "openable";
2744
+ };
2745
+ return async (sessionId) => {
2746
+ const sessions = access.getSessions();
2747
+ if (sessions === void 0) return "unavailable";
2748
+ try {
2749
+ let state = lookup(sessions, access.getWorkspaces(), sessionId);
2750
+ if (state === "absent") {
2751
+ try {
2752
+ await sessions.refresh();
2753
+ } catch {}
2754
+ state = lookup(sessions, access.getWorkspaces(), sessionId);
2755
+ }
2756
+ if (state === "archived") return "archived";
2757
+ if (state === "absent") return "missing";
2758
+ sessions.open(sessionId);
2759
+ return "opened";
2760
+ } catch {
2761
+ return "unavailable";
2762
+ }
2763
+ };
2764
+ }
2765
+
2023
2766
  //#endregion
2024
2767
  //#region src/client/index.ts
2025
2768
  /**
@@ -2059,6 +2802,10 @@ window.__ModuleLoader__.load({
2059
2802
  });
2060
2803
  return out;
2061
2804
  };
2805
+ controller.installSessionJumper(createSessionJumper({
2806
+ getSessions: () => ctx.get?.("sessions"),
2807
+ getWorkspaces: () => ctx.get?.("workspaces")
2808
+ }));
2062
2809
  controller.start();
2063
2810
  const disposers = [];
2064
2811
  try {