bosun 0.29.2 → 0.29.4

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.
@@ -469,19 +469,30 @@ export function WorkspaceSwitcher() {
469
469
 
470
470
  const activeWs = workspaces.value.find((ws) => ws.id === activeWorkspaceId.value);
471
471
  const wsList = workspaces.value;
472
+ const isLoading = workspacesLoading.value;
472
473
 
473
- if (!wsList.length && !managerOpen) return null;
474
+ // Always render even with no workspaces, show a manage button so users can add one
474
475
 
475
476
  return html`
476
477
  <div class="ws-switcher">
477
478
  <button
478
- class="ws-switcher-btn"
479
- onClick=${(e) => { e.stopPropagation(); haptic("light"); setOpen(!open); }}
480
- title="Switch workspace"
479
+ class="ws-switcher-btn ${!wsList.length ? 'ws-switcher-btn-empty' : ''}"
480
+ onClick=${(e) => {
481
+ e.stopPropagation();
482
+ haptic("light");
483
+ if (!wsList.length) {
484
+ setManagerOpen(true);
485
+ } else {
486
+ setOpen(!open);
487
+ }
488
+ }}
489
+ title=${wsList.length ? "Switch workspace" : "Set up a workspace"}
481
490
  >
482
491
  <span class="ws-switcher-icon">⬡</span>
483
- <span class="ws-switcher-name">${activeWs?.name || "Select Workspace"}</span>
484
- <span class="ws-switcher-chevron ${open ? "open" : ""}">${open ? "" : ""}</span>
492
+ <span class="ws-switcher-name">
493
+ ${isLoading ? "Loading…" : (activeWs?.name || (wsList.length ? "Select Workspace" : "Set up workspace"))}
494
+ </span>
495
+ ${wsList.length ? html`<span class="ws-switcher-chevron ${open ? "open" : ""}">${open ? "▴" : "▾"}</span>` : null}
485
496
  </button>
486
497
 
487
498
  ${open && html`
package/ui/demo.html CHANGED
@@ -237,6 +237,24 @@
237
237
  version: '0.26.2',
238
238
  tasks: { completed: 13, failed: 1, retried: 2, active: 2, queued: 3 },
239
239
  prs: { created: 15, merged: 11, pending: 2, ciFailures: 1 },
240
+ // counts consumed by dashboard metrics
241
+ counts: {
242
+ running: 2,
243
+ inprogress: 2,
244
+ inreview: 1,
245
+ review: 1,
246
+ done: 2,
247
+ todo: 2,
248
+ draft: 1,
249
+ error: 0,
250
+ },
251
+ backlog_remaining: 2,
252
+ // consumed by dashboard quality section
253
+ success_metrics: {
254
+ first_shot_rate: 72,
255
+ needed_fix: 3,
256
+ failed: 1,
257
+ },
240
258
  };
241
259
 
242
260
  const SEED_EXECUTORS = [
@@ -668,7 +686,7 @@
668
686
  if (route === '/api/status')
669
687
  return { data: STATE.status };
670
688
  if (route === '/api/executor')
671
- return { data: { ...STATE.status, maxParallel: STATE.maxParallel, paused: STATE.paused, executors: STATE.executors } };
689
+ return { data: { ...STATE.status, maxParallel: STATE.maxParallel, paused: STATE.paused, executors: STATE.executors, activeSlots: STATE.executors.filter(e => e.status === 'active').length, sdk: 'auto' } };
672
690
  if (route === '/api/telemetry/summary')
673
691
  return { data: { status: 'ok', updatedAt: Date.now(), totals: { tasks: STATE.tasks.length, executors: STATE.executors.length } } };
674
692
  if (route === '/api/telemetry/errors')
@@ -35,8 +35,9 @@
35
35
  background: rgba(5, 6, 8, 0.5);
36
36
  opacity: 0;
37
37
  pointer-events: none;
38
- transition: opacity 0.2s ease;
38
+ transition: opacity 0.28s cubic-bezier(0.32, 0.72, 0, 1);
39
39
  z-index: 140;
40
+ -webkit-tap-highlight-color: transparent;
40
41
  }
41
42
 
42
43
  .session-drawer-backdrop.open {
@@ -93,9 +94,11 @@
93
94
  bottom: calc(var(--nav-height) + var(--safe-bottom));
94
95
  width: min(88vw, 360px);
95
96
  transform: translateX(-100%);
96
- transition: transform 0.24s ease;
97
+ transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
98
+ will-change: transform;
97
99
  z-index: 160;
98
100
  box-shadow: 12px 0 40px rgba(0, 0, 0, 0.35);
101
+ background: var(--bg-secondary);
99
102
  }
100
103
  .session-split.drawer-open .session-pane {
101
104
  transform: translateX(0);
@@ -150,7 +153,7 @@
150
153
  display: flex;
151
154
  align-items: center;
152
155
  padding: 0;
153
- border-bottom: 1px solid var(--border, var(--border));
156
+ border-bottom: 1px solid var(--border);
154
157
  background: var(--bg-primary);
155
158
  flex-shrink: 0;
156
159
  }
@@ -177,6 +180,7 @@
177
180
  font-size: 12px;
178
181
  font-weight: 600;
179
182
  cursor: pointer;
183
+ transition: color var(--transition-fast), border-color var(--transition-fast);
180
184
  }
181
185
 
182
186
  .session-drawer-btn:hover {
@@ -184,6 +188,17 @@
184
188
  border-color: var(--border-strong);
185
189
  }
186
190
 
191
+ .session-drawer-btn:active {
192
+ transform: scale(0.96);
193
+ }
194
+
195
+ /* On tablet+, the session pane is inline — hide the hamburger drawer toggle */
196
+ @media (min-width: 769px) {
197
+ .session-drawer-btn {
198
+ display: none;
199
+ }
200
+ }
201
+
187
202
  .chat-shell-title {
188
203
  display: flex;
189
204
  flex-direction: column;
@@ -569,6 +584,12 @@
569
584
  .chat-stream-dot.executing,
570
585
  .chat-stream-dot.streaming {
571
586
  background: var(--accent);
587
+ animation: streamDotPulse 1.8s ease-in-out infinite;
588
+ }
589
+
590
+ @keyframes streamDotPulse {
591
+ 0%, 100% { box-shadow: 0 0 0 4px rgba(218, 119, 86, 0.1); }
592
+ 50% { box-shadow: 0 0 0 6px rgba(218, 119, 86, 0.25); }
572
593
  }
573
594
 
574
595
  .chat-stream-dot.paused {
@@ -749,7 +770,12 @@
749
770
  flex: 1;
750
771
  min-height: 0;
751
772
  overflow-y: auto;
773
+ overflow-x: hidden;
752
774
  -webkit-overflow-scrolling: touch;
775
+ overscroll-behavior-y: contain;
776
+ scroll-padding-bottom: 80px;
777
+ scrollbar-width: thin;
778
+ scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
753
779
  padding: 20px 16px 48px;
754
780
  display: flex;
755
781
  flex-direction: column;
@@ -757,6 +783,23 @@
757
783
  align-items: stretch;
758
784
  }
759
785
 
786
+ .chat-messages::-webkit-scrollbar {
787
+ width: 6px;
788
+ }
789
+
790
+ .chat-messages::-webkit-scrollbar-track {
791
+ background: transparent;
792
+ }
793
+
794
+ .chat-messages::-webkit-scrollbar-thumb {
795
+ background: rgba(255, 255, 255, 0.1);
796
+ border-radius: 3px;
797
+ }
798
+
799
+ .chat-messages::-webkit-scrollbar-thumb:hover {
800
+ background: rgba(255, 255, 255, 0.18);
801
+ }
802
+
760
803
  .chat-load-earlier {
761
804
  display: flex;
762
805
  align-items: center;
@@ -821,6 +864,11 @@
821
864
  background: var(--bg-card);
822
865
  align-self: flex-start;
823
866
  box-shadow: var(--shadow-sm);
867
+ /* CSS containment — tells the browser each bubble is layout-independent,
868
+ reducing style recalc and layout thrashing on long message lists */
869
+ content-visibility: auto;
870
+ contain-intrinsic-size: auto 80px;
871
+ contain: layout style;
824
872
  }
825
873
 
826
874
  .chat-bubble.user {
@@ -888,19 +936,50 @@
888
936
  margin-top: 4px;
889
937
  }
890
938
 
939
+ /* Subtle entrance for bubbles — only the last few get the animation
940
+ (content-visibility: auto on older bubbles skips them for free) */
941
+ .chat-bubble:last-child,
942
+ .chat-bubble:nth-last-child(2),
943
+ .chat-bubble:nth-last-child(3) {
944
+ animation: chatBubbleIn 0.18s ease-out;
945
+ }
946
+
947
+ @keyframes chatBubbleIn {
948
+ from { opacity: 0; transform: translateY(6px); }
949
+ to { opacity: 1; transform: translateY(0); }
950
+ }
951
+
891
952
  .chat-jump-latest {
892
953
  position: absolute;
893
954
  left: 0;
894
955
  right: 0;
895
- bottom: 16px;
956
+ /* position above the input bar (~60px) + gap */
957
+ bottom: 72px;
896
958
  display: flex;
897
959
  justify-content: center;
898
960
  pointer-events: none;
899
961
  z-index: 6;
962
+ animation: fadeSlideUp 0.2s ease;
900
963
  }
901
964
 
902
965
  .chat-jump-latest .btn {
903
966
  pointer-events: auto;
967
+ border-radius: var(--radius-full);
968
+ padding: 6px 16px;
969
+ font-size: 12px;
970
+ font-weight: 600;
971
+ box-shadow: var(--shadow-md);
972
+ backdrop-filter: blur(8px);
973
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
974
+ }
975
+
976
+ .chat-jump-latest .btn:hover {
977
+ transform: translateY(-1px);
978
+ box-shadow: var(--shadow-lg);
979
+ }
980
+
981
+ .chat-jump-latest .btn:active {
982
+ transform: scale(0.96);
904
983
  }
905
984
 
906
985
  /* ─── Code blocks ─── */
@@ -1253,8 +1332,8 @@ ul.md-list li::before {
1253
1332
  right: 0;
1254
1333
  max-height: 240px;
1255
1334
  overflow-y: auto;
1256
- background: var(--bg-card, var(--bg-card));
1257
- border: 1px solid var(--border, var(--border));
1335
+ background: var(--bg-card);
1336
+ border: 1px solid var(--border);
1258
1337
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1259
1338
  box-shadow: var(--shadow-lg, 0 4px 24px rgba(0,0,0,.35));
1260
1339
  z-index: 9999;
@@ -1345,7 +1424,7 @@ ul.md-list li::before {
1345
1424
  .chat-input-area {
1346
1425
  position: relative;
1347
1426
  padding: 16px 16px;
1348
- border-top: 1px solid var(--border, var(--border));
1427
+ border-top: 1px solid var(--border);
1349
1428
  background: var(--bg-surface, var(--bg-card));
1350
1429
  flex-shrink: 0;
1351
1430
  }
@@ -1391,20 +1470,12 @@ ul.md-list li::before {
1391
1470
  padding: 0 0 calc(12px + var(--safe-bottom, 0px));
1392
1471
  }
1393
1472
 
1394
- .app-shell[data-tab="chat"] .chat-view-embedded .chat-messages {
1395
- padding: 0 0 16px;
1396
- }
1397
-
1398
- .app-shell[data-tab="chat"] .chat-input-area {
1399
- padding: 0 0 calc(12px + var(--safe-bottom, 0px));
1400
- }
1401
-
1402
1473
  .chat-input-wrapper {
1403
1474
  display: flex;
1404
1475
  align-items: flex-end;
1405
1476
  gap: 8px;
1406
- background: var(--bg-card, var(--bg-card));
1407
- border: 1px solid var(--border, var(--border));
1477
+ background: var(--bg-card);
1478
+ border: 1px solid var(--border);
1408
1479
  border-radius: 24px;
1409
1480
  padding: 8px 8px 8px 16px;
1410
1481
  transition: border-color 0.15s, box-shadow 0.15s;
@@ -1413,8 +1484,8 @@ ul.md-list li::before {
1413
1484
  }
1414
1485
 
1415
1486
  .chat-input-wrapper:focus-within {
1416
- border-color: var(--accent, var(--color-primary, #3b82f6));
1417
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
1487
+ border-color: var(--accent);
1488
+ box-shadow: 0 0 0 2px var(--accent-soft, rgba(218, 119, 86, 0.12));
1418
1489
  }
1419
1490
 
1420
1491
  .chat-textarea {
@@ -1430,6 +1501,7 @@ ul.md-list li::before {
1430
1501
  min-height: 24px;
1431
1502
  max-height: 120px;
1432
1503
  padding: 4px 0;
1504
+ scrollbar-width: thin;
1433
1505
  }
1434
1506
 
1435
1507
  .chat-textarea::placeholder {
@@ -1514,8 +1586,8 @@ ul.md-list li::before {
1514
1586
  justify-content: center;
1515
1587
  border: none;
1516
1588
  cursor: pointer;
1517
- padding: 0 16px;
1518
- min-width: 60px;
1589
+ padding: 0 10px;
1590
+ min-width: 68px;
1519
1591
  font-size: 11px;
1520
1592
  font-family: inherit;
1521
1593
  gap: 2px;
@@ -1556,6 +1628,7 @@ ul.md-list li::before {
1556
1628
  font-weight: 600;
1557
1629
  text-transform: uppercase;
1558
1630
  letter-spacing: 0.3px;
1631
+ white-space: nowrap;
1559
1632
  }
1560
1633
 
1561
1634
  @keyframes session-pulse {
@@ -1752,3 +1825,76 @@ ul.md-list li::before {
1752
1825
  background: rgba(245, 158, 11, 0.1);
1753
1826
  border-radius: 4px;
1754
1827
  }
1828
+
1829
+ /* ═══ Mobile Chat Refinements ═══ */
1830
+ @media (max-width: 768px) {
1831
+ .chat-shell-inner {
1832
+ padding: 8px 10px;
1833
+ gap: 8px;
1834
+ }
1835
+
1836
+ .chat-shell-name {
1837
+ font-size: 13px;
1838
+ }
1839
+
1840
+ .chat-shell-meta {
1841
+ font-size: 10px;
1842
+ }
1843
+
1844
+ .chat-input-wrapper {
1845
+ padding: 6px 6px 6px 12px;
1846
+ border-radius: 20px;
1847
+ }
1848
+
1849
+ .chat-textarea {
1850
+ font-size: 16px; /* prevent iOS zoom on focus */
1851
+ max-height: 100px;
1852
+ }
1853
+
1854
+ .chat-input-area .chat-send-btn {
1855
+ width: 34px;
1856
+ height: 34px;
1857
+ font-size: 15px;
1858
+ }
1859
+
1860
+ .chat-input-hint {
1861
+ font-size: 10px;
1862
+ padding: 3px 2px 0;
1863
+ }
1864
+
1865
+ .chat-bubble {
1866
+ padding: 8px 12px;
1867
+ font-size: 14px;
1868
+ }
1869
+
1870
+ .chat-bubble.user {
1871
+ max-width: min(85%, 480px);
1872
+ }
1873
+
1874
+ .chat-messages {
1875
+ padding: 12px 10px 40px;
1876
+ gap: 8px;
1877
+ }
1878
+ }
1879
+
1880
+ /* ═══ Desktop Chat Refinements ═══ */
1881
+ @media (min-width: 1200px) {
1882
+ .chat-messages {
1883
+ padding: 24px 20px 48px;
1884
+ gap: 10px;
1885
+ }
1886
+
1887
+ .chat-shell-inner {
1888
+ max-width: 1080px;
1889
+ padding: 12px 16px;
1890
+ }
1891
+
1892
+ /* Wider input area on large screens */
1893
+ .chat-input-wrapper {
1894
+ max-width: min(840px, 100%);
1895
+ }
1896
+
1897
+ .chat-input-hint {
1898
+ max-width: min(840px, 100%);
1899
+ }
1900
+ }
@@ -27,6 +27,17 @@
27
27
  border-color: var(--color-primary, #10b981);
28
28
  }
29
29
 
30
+ .ws-switcher-btn.ws-switcher-btn-empty {
31
+ border-style: dashed;
32
+ opacity: 0.8;
33
+ font-style: italic;
34
+ }
35
+
36
+ .ws-switcher-btn.ws-switcher-btn-empty:hover {
37
+ opacity: 1;
38
+ font-style: normal;
39
+ }
40
+
30
41
  .ws-switcher-icon {
31
42
  font-size: 14px;
32
43
  opacity: 0.7;
@@ -660,6 +671,16 @@
660
671
  to { transform: rotate(360deg); }
661
672
  }
662
673
 
674
+ /* ── Modal toolbar (inside shared Modal body) ──────────────── */
675
+ .ws-manager-modal-toolbar {
676
+ display: flex;
677
+ align-items: center;
678
+ gap: 8px;
679
+ margin-bottom: 12px;
680
+ padding-bottom: 12px;
681
+ border-bottom: 1px solid var(--border);
682
+ }
683
+
663
684
  /* ── Responsive ──────────────────────────────────────────── */
664
685
  @media (max-width: 540px) {
665
686
  .ws-manager-panel {
package/ui/tabs/chat.js CHANGED
@@ -386,6 +386,16 @@ export function ChatTab() {
386
386
  } catch { /* signal read error - ignore */ }
387
387
  }, [isMobile]);
388
388
 
389
+ /* ── Auto-focus textarea when switching sessions (desktop only) ── */
390
+ useEffect(() => {
391
+ if (!sessionId || isMobile) return;
392
+ // Delay focus to let the ChatView mount first
393
+ const timer = setTimeout(() => {
394
+ if (textareaRef.current) textareaRef.current.focus();
395
+ }, 150);
396
+ return () => clearTimeout(timer);
397
+ }, [sessionId, isMobile]);
398
+
389
399
  /* ── Slash command filtering ──
390
400
  Use useMemo with no signal deps to avoid subscribing ChatTab to
391
401
  activeAgentInfo. The commands list only matters when the user is
@@ -521,39 +521,33 @@ export function DashboardTab() {
521
521
  `,
522
522
  )}
523
523
  </div>
524
- <//>
525
-
526
- <${Card}
527
- title=${html`<span class="dashboard-card-title"
528
- ><span class="dashboard-title-icon">${ICONS.check}</span>Active Work</span
529
- >`}
530
- className="dashboard-card dashboard-active"
531
- >
532
- <div class="dashboard-work-layout">
533
- <div class="dashboard-work-list">
534
- ${workItems.map(
535
- (item) => html`
536
- <div class="dashboard-work-item">
537
- <div class="dashboard-work-left">
538
- <span
539
- class="dashboard-work-dot"
540
- style="background: ${item.color}"
541
- ></span>
542
- <span class="dashboard-work-label">${item.label}</span>
524
+ ${segments.length > 0 && html`
525
+ <div class="dashboard-work-layout" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
526
+ <div class="dashboard-work-list">
527
+ ${workItems.map(
528
+ (item) => html`
529
+ <div class="dashboard-work-item">
530
+ <div class="dashboard-work-left">
531
+ <span
532
+ class="dashboard-work-dot"
533
+ style="background: ${item.color}"
534
+ ></span>
535
+ <span class="dashboard-work-label">${item.label}</span>
536
+ </div>
537
+ <span class="dashboard-work-value">${item.value}</span>
543
538
  </div>
544
- <span class="dashboard-work-value">${item.value}</span>
545
- </div>
546
- `,
547
- )}
548
- </div>
549
- <div class="dashboard-work-chart">
550
- <${DonutChart} segments=${segments} size=${110} strokeWidth=${10} />
551
- <div class="dashboard-work-meta">
552
- Active progress · ${progressPct}% engaged
539
+ `,
540
+ )}
541
+ </div>
542
+ <div class="dashboard-work-chart">
543
+ <${DonutChart} segments=${segments} size=${90} strokeWidth=${9} />
544
+ <div class="dashboard-work-meta">
545
+ ${progressPct}% engaged
546
+ </div>
547
+ <${ProgressBar} percent=${progressPct} />
553
548
  </div>
554
- <${ProgressBar} percent=${progressPct} />
555
549
  </div>
556
- </div>
550
+ `}
557
551
  <//>
558
552
 
559
553
  <${Card}
@@ -683,8 +677,8 @@ export function DashboardTab() {
683
677
  ${truncate(task.title || "(untitled)", 50)}
684
678
  </div>
685
679
  <div class="meta-text">
686
- ${task.id}${task.updated_at
687
- ? ` · ${formatRelative(task.updated_at)}`
680
+ ${task.id}${(task.updated_at || task.updated)
681
+ ? ` · ${formatRelative(task.updated_at || task.updated)}`
688
682
  : ""}
689
683
  </div>
690
684
  </div>
package/ui/tabs/tasks.js CHANGED
@@ -89,6 +89,15 @@ const SORT_OPTIONS = [
89
89
  { value: "title", label: "Title" },
90
90
  ];
91
91
 
92
+ /* Maps snapshot-bar labels → tasksFilter values */
93
+ const SNAPSHOT_STATUS_MAP = {
94
+ Backlog: "todo",
95
+ Active: "inprogress",
96
+ Review: "inreview",
97
+ Done: "done",
98
+ Errors: "error",
99
+ };
100
+
92
101
  const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, "": 4 };
93
102
 
94
103
  const SYSTEM_TAGS = new Set([
@@ -994,8 +1003,10 @@ export function TasksTab() {
994
1003
  return dir * (av - bv);
995
1004
  }
996
1005
  if (listSortCol === "updated") {
997
- av = a.updated_at ? new Date(a.updated_at).getTime() : 0;
998
- bv = b.updated_at ? new Date(b.updated_at).getTime() : 0;
1006
+ const tsA = a.updated_at || a.updated;
1007
+ const tsB = b.updated_at || b.updated;
1008
+ av = tsA ? (typeof tsA === 'number' ? tsA : new Date(tsA).getTime()) : 0;
1009
+ bv = tsB ? (typeof tsB === 'number' ? tsB : new Date(tsB).getTime()) : 0;
999
1010
  return dir * (av - bv);
1000
1011
  }
1001
1012
  if (listSortCol === "status") { av = a.status || ""; bv = b.status || ""; }
@@ -1609,11 +1620,20 @@ export function TasksTab() {
1609
1620
 
1610
1621
  <div class="snapshot-bar">
1611
1622
  ${summaryMetrics.map((m) => html`
1612
- <span key=${m.label} class="snapshot-pill">
1623
+ <button
1624
+ key=${m.label}
1625
+ class="snapshot-pill snapshot-pill-btn ${!isKanban && filterVal === SNAPSHOT_STATUS_MAP[m.label] ? 'snapshot-pill-active' : ''}"
1626
+ onClick=${() => {
1627
+ if (isKanban) return;
1628
+ const statusVal = SNAPSHOT_STATUS_MAP[m.label];
1629
+ if (statusVal !== undefined) handleFilter(filterVal === statusVal ? 'all' : statusVal);
1630
+ }}
1631
+ title=${isKanban ? m.label : `Filter by ${m.label}`}
1632
+ >
1613
1633
  <span class="snapshot-dot" style="background:${m.color};" />
1614
1634
  <strong class="snapshot-val">${m.value}</strong>
1615
1635
  <span class="snapshot-lbl">${m.label}</span>
1616
- </span>
1636
+ </button>
1617
1637
  `)}
1618
1638
  <span class="snapshot-view-tag">${isKanban ? "⬛ Board" : "☰ List"}</span>
1619
1639
  </div>
@@ -1632,6 +1652,23 @@ export function TasksTab() {
1632
1652
  cursor:pointer;
1633
1653
  }
1634
1654
  .actions-dropdown-item:hover { background:var(--hover-bg, rgba(255,255,255,.08)); }
1655
+ .snapshot-pill-btn {
1656
+ background: none;
1657
+ border: 1px solid transparent;
1658
+ border-radius: 999px;
1659
+ cursor: pointer;
1660
+ padding: 2px 8px;
1661
+ transition: border-color 0.15s, background 0.15s;
1662
+ font: inherit;
1663
+ }
1664
+ .snapshot-pill-btn:hover {
1665
+ border-color: var(--border);
1666
+ background: var(--bg-card-hover, rgba(255,255,255,0.05));
1667
+ }
1668
+ .snapshot-pill-active {
1669
+ border-color: var(--accent) !important;
1670
+ background: rgba(59,130,246,0.12) !important;
1671
+ }
1635
1672
  @media (max-width: 640px) {
1636
1673
  .actions-label { display:none; }
1637
1674
  }
@@ -1706,8 +1743,8 @@ export function TasksTab() {
1706
1743
  : html`<span class="task-td-empty">—</span>`}
1707
1744
  </td>
1708
1745
  <td class="task-td task-td-updated">
1709
- ${task.updated_at
1710
- ? html`<span class="task-td-date">${formatRelative(task.updated_at)}</span>`
1746
+ ${(task.updated_at || task.updated)
1747
+ ? html`<span class="task-td-date">${formatRelative(task.updated_at || task.updated)}</span>`
1711
1748
  : html`<span class="task-td-empty">—</span>`}
1712
1749
  </td>
1713
1750
  </tr>
package/ui-server.mjs CHANGED
@@ -3435,6 +3435,18 @@ async function handleApi(req, res, url) {
3435
3435
  return;
3436
3436
  }
3437
3437
 
3438
+ if (path === "/api/workspace-health") {
3439
+ try {
3440
+ const { runWorkspaceHealthCheck } = await import("./config-doctor.mjs");
3441
+ const configDir = resolveUiConfigDir();
3442
+ const result = runWorkspaceHealthCheck({ configDir });
3443
+ jsonResponse(res, 200, { ok: result.ok, data: result });
3444
+ } catch (err) {
3445
+ jsonResponse(res, 500, { ok: false, error: err.message });
3446
+ }
3447
+ return;
3448
+ }
3449
+
3438
3450
  if (path === "/api/worktrees") {
3439
3451
  try {
3440
3452
  const worktrees = listActiveWorktrees(repoRoot);
@@ -26,6 +26,39 @@ import { execSync, spawnSync } from "node:child_process";
26
26
 
27
27
  const TAG = "[workspace-manager]";
28
28
 
29
+ // Lazy-loaded reference to repo-config.mjs (resolved on first use)
30
+ let _repoConfigModule = null;
31
+
32
+ /**
33
+ * Ensure repo-level AI executor configs exist after clone/pull.
34
+ * Uses synchronous import cache — repo-config.mjs is pure sync.
35
+ * @param {string} repoPath Absolute path to the repo directory
36
+ */
37
+ function ensureRepoAIConfigs(repoPath) {
38
+ try {
39
+ if (!_repoConfigModule) {
40
+ // repo-config.mjs is ESM but fully synchronous internally.
41
+ // We pre-populate the cache via dynamic import at module init.
42
+ return; // Will be populated after first async import
43
+ }
44
+ const { ensureRepoConfigs, printRepoConfigSummary } = _repoConfigModule;
45
+ const result = ensureRepoConfigs(repoPath);
46
+ // Only log if something was created/updated
47
+ const anyChange = Object.values(result).some((r) => r.created || r.updated);
48
+ if (anyChange) {
49
+ console.log(TAG, `Repo-level AI configs for ${basename(repoPath)}:`);
50
+ printRepoConfigSummary(result, (msg) => console.log(TAG, msg));
51
+ }
52
+ } catch (err) {
53
+ console.warn(TAG, `Could not ensure repo AI configs: ${err.message}`);
54
+ }
55
+ }
56
+
57
+ // Pre-load repo-config.mjs asynchronously at module init time
58
+ import("./repo-config.mjs")
59
+ .then((mod) => { _repoConfigModule = mod; })
60
+ .catch(() => { /* repo-config not available — skip */ });
61
+
29
62
  // ── Path Helpers ─────────────────────────────────────────────────────────────
30
63
 
31
64
  /**
@@ -313,8 +346,12 @@ export function addRepoToWorkspace(configDir, workspaceId, { url, name, branch,
313
346
  }
314
347
  cloned = true;
315
348
  console.log(TAG, `Cloned ${repoName} successfully`);
349
+ // Ensure repo-level AI executor configs exist after fresh clone
350
+ ensureRepoAIConfigs(repoPath);
316
351
  } else {
317
352
  console.log(TAG, `Repository ${repoName} already exists at ${repoPath}`);
353
+ // Ensure repo-level configs are up to date even for existing repos
354
+ ensureRepoAIConfigs(repoPath);
318
355
  }
319
356
 
320
357
  const slug = extractSlug(url);