dsh-taskboard 0.1.2 → 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,6 +402,32 @@ window.__ModuleLoader__.load({
349
402
  toggleSecondary() {
350
403
  this.setState({ secondaryOpen: !this.state.secondaryOpen });
351
404
  }
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
+ }
352
431
  /** Create a task (composer submit); returns the new task id, undefined on failure. */
353
432
  async create(body) {
354
433
  try {
@@ -425,6 +504,15 @@ window.__ModuleLoader__.load({
425
504
  this.setState({ error: error instanceof Error ? error.message : String(error) });
426
505
  }
427
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
+ }
428
516
  /** Soft-delete (agent parity) then optional purge. */
429
517
  async remove(id, ifVersion, purge) {
430
518
  try {
@@ -435,6 +523,93 @@ window.__ModuleLoader__.load({
435
523
  this.setState({ error: error instanceof Error ? error.message : String(error) });
436
524
  }
437
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
+ }
438
613
  };
439
614
 
440
615
  //#endregion
@@ -450,7 +625,7 @@ window.__ModuleLoader__.load({
450
625
  /** The stylesheet text. */
451
626
  const STYLES = `
452
627
  .dsh-atb-entry {
453
- display: flex; align-items: center; gap: 8px;
628
+ display: flex; align-items: center; gap: 8px; position: relative;
454
629
  width: calc(100% - 8px); margin: 2px 4px; padding: 6px 10px;
455
630
  border: none; border-radius: 8px; background: transparent;
456
631
  color: var(--dsw-text-secondary, inherit); font: inherit; font-size: 13px;
@@ -459,6 +634,35 @@ window.__ModuleLoader__.load({
459
634
  .dsh-atb-entry:hover { background: var(--dsw-hover, rgba(128,128,128,.12)); color: var(--dsw-text-primary, inherit); }
460
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; }
461
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; }
462
666
 
463
667
  html[data-dsh-atb-active] [data-pane="conversation"] > *:not([data-dsh-atb-view]) { display: none !important; }
464
668
  .dsh-atb-view { display: none; }
@@ -468,6 +672,16 @@ window.__ModuleLoader__.load({
468
672
  .dsh-atb-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
469
673
  .dsh-atb-title { font-size: 15px; font-weight: 600; margin: 0; }
470
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; }
471
685
  .dsh-atb-spacer { flex: 1; }
472
686
  .dsh-atb-select, .dsh-atb-input {
473
687
  font: inherit; font-size: 12.5px; padding: 5px 8px; border-radius: 7px;
@@ -488,6 +702,16 @@ window.__ModuleLoader__.load({
488
702
  .dsh-atb-dot[data-urgency="urgent"] { background: #e5484d; }
489
703
  .dsh-atb-dot[data-urgency="normal"] { background: #8e4ec6; }
490
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; }
491
715
 
492
716
  .dsh-atb-btn {
493
717
  font: inherit; font-size: 12.5px; padding: 5px 11px; border-radius: 7px; cursor: pointer;
@@ -618,6 +842,7 @@ window.__ModuleLoader__.load({
618
842
  transition: filter .12s ease;
619
843
  }
620
844
  .dsh-atb-detail-run:hover { filter: brightness(1.1); }
845
+ .dsh-atb-detail-run[data-danger="true"] { background: rgba(229,72,77,.92); }
621
846
  .dsh-atb-movebtns { display: flex; gap: 6px; flex-wrap: wrap; }
622
847
  .dsh-atb-movebtn {
623
848
  font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; cursor: pointer;
@@ -696,7 +921,11 @@ window.__ModuleLoader__.load({
696
921
  .dsh-atb-exec-outcome[data-outcome="running"] { background: rgba(217,130,43,.15); color: #d9822b; }
697
922
  .dsh-atb-exec-outcome[data-outcome="cancelled"] { background: rgba(128,128,128,.15); color: var(--dsw-text-secondary, gray); }
698
923
  .dsh-atb-exec-time { font-size: 11px; color: var(--dsw-text-secondary, gray); }
699
- .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; }
700
929
  .dsh-atb-exec-error { flex-basis: 100%; font-size: 11px; color: #e5484d; word-break: break-all; }
701
930
 
702
931
  .dsh-atb-dangerzone {
@@ -884,12 +1113,108 @@ window.__ModuleLoader__.load({
884
1113
  entry.dataset.dshAtbEntry = "";
885
1114
  entry.className = "dsh-atb-entry";
886
1115
  entry.setAttribute("aria-label", "Agent 任务看板");
887
- 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>`;
888
1117
  entry.addEventListener("click", () => {
889
1118
  controller.toggleBoard();
890
1119
  });
891
1120
  return entry;
892
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
+ }
893
1218
  /** Re-insert the entry after the New Session row (before the browser region). */
894
1219
  function placeEntry(root, entry) {
895
1220
  const button = newSessionButton(root);
@@ -960,9 +1285,11 @@ window.__ModuleLoader__.load({
960
1285
  const retry = setInterval(() => {
961
1286
  tryPlace();
962
1287
  }, 2e3);
1288
+ const syncStats = wireStats(entry, controller);
963
1289
  const syncActive = () => {
964
1290
  if (controller.getSnapshot().boardOpen) entry.dataset.active = "true";
965
1291
  else delete entry.dataset.active;
1292
+ syncStats();
966
1293
  };
967
1294
  const unsubscribe = controller.subscribe(syncActive);
968
1295
  syncActive();
@@ -976,6 +1303,17 @@ window.__ModuleLoader__.load({
976
1303
  };
977
1304
  }
978
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
+
979
1317
  //#endregion
980
1318
  //#region src/client/board/TaskCard.tsx
981
1319
  const URGENCY_LABEL$1 = {
@@ -996,17 +1334,20 @@ window.__ModuleLoader__.load({
996
1334
  * @param task - the task record.
997
1335
  * @param controller - the controller.
998
1336
  * @param draggable - enable dragging.
1337
+ * @param now - current epoch ms (stale-claim highlight).
999
1338
  * @param onAlert - show an alert message (replaces native alert).
1000
1339
  */
1001
- function TaskCard({ task, controller, draggable = false, onAlert }) {
1340
+ function TaskCard({ task, controller, draggable = false, now, onAlert }) {
1002
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);
1003
1344
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1004
1345
  type: "button",
1005
1346
  className: "dsh-atb-card",
1006
1347
  "data-urgency": task.urgency,
1007
1348
  draggable,
1008
1349
  onDragStart: (e) => {
1009
- if (task.executions.find((ex) => ex.outcome === "running") !== void 0) {
1350
+ if (running !== void 0) {
1010
1351
  e.preventDefault();
1011
1352
  const msg = `该任务正在由【${task.title}】会话执行,不能拖动`;
1012
1353
  if (onAlert !== void 0) onAlert(msg);
@@ -1036,6 +1377,11 @@ window.__ModuleLoader__.load({
1036
1377
  "data-kind": "blocked",
1037
1378
  children: "受阻"
1038
1379
  }),
1380
+ stale && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1381
+ className: "dsh-atb-badge",
1382
+ "data-kind": "stale",
1383
+ children: "⏱ 认领超时"
1384
+ }),
1039
1385
  task.execution.mode === "scheduled" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1040
1386
  className: "dsh-atb-badge",
1041
1387
  "data-kind": "scheduled",
@@ -1070,6 +1416,62 @@ window.__ModuleLoader__.load({
1070
1416
  });
1071
1417
  }
1072
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
+
1073
1475
  //#endregion
1074
1476
  //#region src/client/board/TaskDetail.tsx
1075
1477
  /**
@@ -1114,10 +1516,10 @@ window.__ModuleLoader__.load({
1114
1516
  failed: "失败",
1115
1517
  cancelled: "已取消"
1116
1518
  };
1117
- /** Compact session-id display. */
1519
+ /** Compact session-id display (execution sessions carry the taskboard infix). */
1118
1520
  function shortId(id) {
1119
1521
  if (id === void 0) return "";
1120
- return id.replace(/^session-/, "").slice(0, 8);
1522
+ return id.replace(/^session-(taskboard-)?/, "").slice(0, 8);
1121
1523
  }
1122
1524
  /** Execution duration between start and end. */
1123
1525
  function duration(startedAt, endedAt) {
@@ -1142,13 +1544,27 @@ window.__ModuleLoader__.load({
1142
1544
  * The detail view.
1143
1545
  * @param task - the task record.
1144
1546
  * @param controller - the controller.
1547
+ * @param now - current epoch ms (stale-claim highlight).
1145
1548
  */
1146
- function TaskDetail({ task, controller }) {
1549
+ function TaskDetail({ task, controller, now }) {
1147
1550
  const [comment, setComment] = (0, react.useState)("");
1148
1551
  const [confirmDone, setConfirmDone] = (0, react.useState)(false);
1149
1552
  const [confirmPurge, setConfirmPurge] = (0, react.useState)(false);
1553
+ const [confirmCancel, setConfirmCancel] = (0, react.useState)(false);
1554
+ const { alert: showAlert, el: alertEl } = useAlert();
1150
1555
  const ws = controller.getSnapshot().workspaces.find((w) => w.id === task.workspaceId);
1151
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
+ };
1152
1568
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1153
1569
  className: "dsh-atb-detail",
1154
1570
  "data-urgency": task.urgency,
@@ -1194,6 +1610,15 @@ window.__ModuleLoader__.load({
1194
1610
  tone: "urgent",
1195
1611
  children: "受阻"
1196
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
+ }),
1197
1622
  task.trashedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Chip, {
1198
1623
  icon: "🗑",
1199
1624
  tone: "urgent",
@@ -1221,6 +1646,13 @@ window.__ModuleLoader__.load({
1221
1646
  onClick: () => controller.openEditor(task.id),
1222
1647
  children: "✎ 编辑"
1223
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
+ }),
1224
1656
  canRun && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1225
1657
  type: "button",
1226
1658
  className: "dsh-atb-detail-run",
@@ -1228,6 +1660,38 @@ window.__ModuleLoader__.load({
1228
1660
  onClick: () => void controller.run(task.id),
1229
1661
  children: "▶ 立即执行"
1230
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
+ })),
1231
1695
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1232
1696
  type: "button",
1233
1697
  className: "dsh-atb-detail-close",
@@ -1263,49 +1727,60 @@ window.__ModuleLoader__.load({
1263
1727
  className: "dsh-atb-detail-actions",
1264
1728
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1265
1729
  className: "dsh-atb-movebtns",
1266
- children: [moveTargets(task).map((to) => to === "done" ? confirmDone ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1267
- className: "dsh-atb-confirm",
1268
- children: [
1269
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1270
- className: "dsh-atb-confirm-label",
1271
- children: "确认完成?"
1272
- }),
1273
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1274
- type: "button",
1275
- className: "dsh-atb-btn",
1276
- "data-primary": "true",
1277
- onClick: () => {
1278
- controller.move(task.id, task.version, "done");
1279
- setConfirmDone(false);
1280
- },
1281
- children: "确认"
1282
- }),
1283
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1284
- type: "button",
1285
- className: "dsh-atb-btn",
1286
- onClick: () => setConfirmDone(false),
1287
- children: "取消"
1288
- })
1289
- ]
1290
- }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1291
- type: "button",
1292
- className: "dsh-atb-movebtn",
1293
- "data-to": to,
1294
- onClick: () => setConfirmDone(true),
1295
- children: ["移至→", MOVE_LABEL[to]]
1296
- }, to) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1297
- type: "button",
1298
- className: "dsh-atb-movebtn",
1299
- "data-to": to,
1300
- onClick: () => void controller.move(task.id, task.version, to),
1301
- children: ["移至→", MOVE_LABEL[to]]
1302
- }, to)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1303
- type: "button",
1304
- className: "dsh-atb-movebtn",
1305
- "data-to": "blocked",
1306
- onClick: () => void controller.toggleBlocked(task),
1307
- children: task.blocked ? "✓ 解除受阻" : "⛔ 标记受阻"
1308
- })]
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
+ ]
1309
1784
  })
1310
1785
  }),
1311
1786
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1366,12 +1841,24 @@ window.__ModuleLoader__.load({
1366
1841
  }),
1367
1842
  task.executions.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1368
1843
  className: "dsh-atb-section",
1369
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h4", { children: ["执行记录", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1370
- className: "dsh-atb-count2",
1371
- children: task.executions.length
1372
- })] }), /* @__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", {
1373
1860
  className: "dsh-atb-execlist",
1374
- 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", {
1375
1862
  className: "dsh-atb-exec-row",
1376
1863
  children: [
1377
1864
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1391,10 +1878,16 @@ window.__ModuleLoader__.load({
1391
1878
  className: "dsh-atb-exec-time",
1392
1879
  children: [fmtTime(e.startedAt), e.endedAt !== void 0 && ` · ${duration(e.startedAt, e.endedAt)}`]
1393
1880
  }),
1394
- 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",
1395
1883
  className: "dsh-atb-exec-session",
1396
- title: e.sessionId,
1397
- children: ["🤖 ", shortId(e.sessionId)]
1884
+ title: `点击打开该执行会话:${e.sessionId}`,
1885
+ onClick: () => jumpToSession(e.sessionId),
1886
+ children: [
1887
+ "🤖 ",
1888
+ shortId(e.sessionId),
1889
+ " ↗"
1890
+ ]
1398
1891
  }),
1399
1892
  e.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1400
1893
  className: "dsh-atb-exec-error",
@@ -1444,7 +1937,8 @@ window.__ModuleLoader__.load({
1444
1937
  onClick: () => setConfirmPurge(true),
1445
1938
  children: "🔥 物理清除(需确认)"
1446
1939
  })
1447
- })
1940
+ }),
1941
+ alertEl
1448
1942
  ]
1449
1943
  });
1450
1944
  }
@@ -1733,7 +2227,7 @@ window.__ModuleLoader__.load({
1733
2227
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1734
2228
  value: prompt,
1735
2229
  onChange: (e) => setPrompt(e.target.value),
1736
- placeholder: "发给执行会话的完整指令"
2230
+ placeholder: "发给执行会话的完整指令。支持模板变量:{{lastExecution}}(上次执行结果)、{{lastComments}}(最近 3 条评论)"
1737
2231
  })
1738
2232
  }),
1739
2233
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
@@ -1833,62 +2327,6 @@ window.__ModuleLoader__.load({
1833
2327
  });
1834
2328
  }
1835
2329
 
1836
- //#endregion
1837
- //#region src/client/board/AlertModal.tsx
1838
- /**
1839
- * A lightweight alert modal — replaces native alert() with a themed overlay
1840
- * that matches the shell design tokens.
1841
- *
1842
- * @module dsh-taskboard/client/board/AlertModal
1843
- */
1844
- /** Show a non-blocking alert modal. Returns true when opened. */
1845
- function useAlert() {
1846
- const [msg, setMsg] = (0, react.useState)(null);
1847
- const show = (m) => setMsg(m);
1848
- const close = () => setMsg(null);
1849
- return {
1850
- alert: show,
1851
- el: msg !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AlertModal, {
1852
- message: msg,
1853
- onClose: close
1854
- }) : null
1855
- };
1856
- }
1857
- function AlertModal({ message, onClose }) {
1858
- (0, react.useEffect)(() => {
1859
- const handler = (e) => {
1860
- if (e.key === "Escape") onClose();
1861
- };
1862
- window.addEventListener("keydown", handler);
1863
- return () => window.removeEventListener("keydown", handler);
1864
- }, [onClose]);
1865
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1866
- className: "dsh-atb-alert-backdrop",
1867
- onClick: onClose,
1868
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1869
- className: "dsh-atb-alert",
1870
- onClick: (e) => e.stopPropagation(),
1871
- children: [
1872
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1873
- className: "dsh-atb-alert-icon",
1874
- children: "⛔"
1875
- }),
1876
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1877
- className: "dsh-atb-alert-msg",
1878
- children: message
1879
- }),
1880
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1881
- type: "button",
1882
- className: "dsh-atb-btn",
1883
- "data-primary": "true",
1884
- onClick: onClose,
1885
- children: "知道了"
1886
- })
1887
- ]
1888
- })
1889
- });
1890
- }
1891
-
1892
2330
  //#endregion
1893
2331
  //#region src/client/board/TaskBoard.tsx
1894
2332
  /**
@@ -1920,9 +2358,28 @@ window.__ModuleLoader__.load({
1920
2358
  const pad = (n) => String(n).padStart(2, "0");
1921
2359
  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
1922
2360
  }
1923
- /** 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. */
1924
2374
  function filterTasks(state, tasks) {
1925
- 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;
1926
2383
  }
1927
2384
  /**
1928
2385
  * The board view root.
@@ -1930,6 +2387,11 @@ window.__ModuleLoader__.load({
1930
2387
  */
1931
2388
  function TaskBoard({ controller }) {
1932
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
+ }, []);
1933
2395
  const live = filterTasks(state, state.ledger.tasks.filter((t) => t.trashedAt === void 0));
1934
2396
  const selected = state.selectedId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.selectedId);
1935
2397
  const { alert: showAlert, el: alertEl } = useAlert();
@@ -1959,6 +2421,13 @@ window.__ModuleLoader__.load({
1959
2421
  children: "+ 新建任务"
1960
2422
  }),
1961
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
+ }),
1962
2431
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1963
2432
  className: "dsh-atb-select",
1964
2433
  value: state.filters.workspaceId ?? "",
@@ -1971,6 +2440,30 @@ window.__ModuleLoader__.load({
1971
2440
  children: ws.title || ws.path
1972
2441
  }, ws.id))]
1973
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
+ }),
1974
2467
  [
1975
2468
  "urgent",
1976
2469
  "normal",
@@ -1991,6 +2484,27 @@ window.__ModuleLoader__.load({
1991
2484
  className: "dsh-atb-btn",
1992
2485
  onClick: () => controller.toggleSecondary(),
1993
2486
  children: state.secondaryOpen ? "返回看板" : "其它任务"
2487
+ }),
2488
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2489
+ type: "button",
2490
+ className: "dsh-atb-btn",
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]
1994
2508
  })
1995
2509
  ]
1996
2510
  }),
@@ -2032,16 +2546,24 @@ window.__ModuleLoader__.load({
2032
2546
  },
2033
2547
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2034
2548
  className: "dsh-atb-colhead",
2035
- children: [COLUMN_LABELS[status], /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2036
- className: "dsh-atb-colcount",
2037
- children: columnTasks.length
2038
- })]
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
+ ]
2039
2560
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2040
2561
  className: "dsh-atb-cards",
2041
2562
  children: [columnTasks.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
2042
2563
  task,
2043
2564
  controller,
2044
2565
  draggable: true,
2566
+ now,
2045
2567
  onAlert: showAlert
2046
2568
  }, task.id)), columnTasks.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2047
2569
  className: "dsh-atb-empty",
@@ -2055,7 +2577,8 @@ window.__ModuleLoader__.load({
2055
2577
  className: "dsh-atb-detailpanel",
2056
2578
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskDetail, {
2057
2579
  task: selected,
2058
- controller
2580
+ controller,
2581
+ now
2059
2582
  })
2060
2583
  }),
2061
2584
  state.composerOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskFormModal, {
@@ -2066,22 +2589,63 @@ window.__ModuleLoader__.load({
2066
2589
  ]
2067
2590
  });
2068
2591
  }
2069
- /** Secondary tab: canceled/archived/trashed rows. */
2592
+ /** Secondary tab: tasks grouped into canceled / archived / trashed columns. */
2070
2593
  function SecondaryTab({ controller, tasks }) {
2071
- const rows = tasks.filter((t) => t.status === "canceled" || t.status === "archived" || t.trashedAt !== void 0);
2072
- 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", {
2073
2615
  className: "dsh-atb-secondary",
2074
- children: [
2075
- rows.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2076
- className: "dsh-atb-empty",
2077
- children: "无已取消 / 已归档 / 已删除任务"
2078
- }),
2079
- rows.map((task) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskCard, {
2080
- task,
2081
- controller
2082
- }, task.id)),
2083
- void 0
2084
- ]
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))
2085
2649
  });
2086
2650
  }
2087
2651
 
@@ -2166,6 +2730,39 @@ window.__ModuleLoader__.load({
2166
2730
  };
2167
2731
  }
2168
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
+
2169
2766
  //#endregion
2170
2767
  //#region src/client/index.ts
2171
2768
  /**
@@ -2205,6 +2802,10 @@ window.__ModuleLoader__.load({
2205
2802
  });
2206
2803
  return out;
2207
2804
  };
2805
+ controller.installSessionJumper(createSessionJumper({
2806
+ getSessions: () => ctx.get?.("sessions"),
2807
+ getWorkspaces: () => ctx.get?.("workspaces")
2808
+ }));
2208
2809
  controller.start();
2209
2810
  const disposers = [];
2210
2811
  try {