@worca/ui 0.31.0 → 0.32.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/app/styles.css CHANGED
@@ -2425,6 +2425,13 @@ sl-details.agent-prompt-section::part(content) {
2425
2425
  margin-bottom: 12px;
2426
2426
  }
2427
2427
 
2428
+ /* Divider + gap before the user message header */
2429
+ .agent-prompt-block + .agent-prompt-block {
2430
+ margin-top: 18px;
2431
+ padding-top: 16px;
2432
+ border-top: 1px solid var(--border-subtle);
2433
+ }
2434
+
2428
2435
  .agent-prompt-label-row {
2429
2436
  display: flex;
2430
2437
  align-items: center;
@@ -2455,12 +2462,14 @@ sl-details.agent-prompt-section::part(content) {
2455
2462
  flex-shrink: 0;
2456
2463
  }
2457
2464
 
2465
+ /* Accent the section headers (not the message bodies) so each block reads as
2466
+ a labelled section; the bodies stay neutral. */
2458
2467
  .agent-prompt-label {
2459
- font-size: 11px;
2460
- font-weight: 600;
2461
- color: var(--muted);
2468
+ font-size: 12px;
2469
+ font-weight: 700;
2470
+ color: var(--accent);
2462
2471
  text-transform: uppercase;
2463
- letter-spacing: 0.5px;
2472
+ letter-spacing: 0.6px;
2464
2473
  margin-bottom: 4px;
2465
2474
  }
2466
2475
 
@@ -4833,7 +4842,7 @@ sl-tooltip.bead-tooltip::part(body) {
4833
4842
 
4834
4843
  .bead-tooltip-excerpt {
4835
4844
  font-size: 12px;
4836
- white-space: pre-wrap;
4845
+ white-space: normal;
4837
4846
  margin-bottom: 2px;
4838
4847
  }
4839
4848
 
@@ -6248,6 +6257,47 @@ sl-dialog.markdown-dialog::part(body) {
6248
6257
  text-align: left;
6249
6258
  }
6250
6259
 
6260
+ /* --- Markdown context overrides --- */
6261
+
6262
+ .markdown-inline.markdown-body {
6263
+ display: inline;
6264
+ font-size: inherit;
6265
+ line-height: inherit;
6266
+ }
6267
+ .markdown-inline.markdown-body p {
6268
+ display: inline;
6269
+ margin: 0;
6270
+ }
6271
+ .markdown-inline.markdown-body code {
6272
+ font-size: 0.85em;
6273
+ }
6274
+
6275
+ .agent-prompt-content .markdown-body {
6276
+ white-space: normal;
6277
+ font-size: 12px;
6278
+ line-height: 1.5;
6279
+ }
6280
+ .agent-prompt-content .markdown-body h1,
6281
+ .agent-prompt-content .markdown-body h2,
6282
+ .agent-prompt-content .markdown-body h3 {
6283
+ margin: 0.8em 0 0.3em;
6284
+ }
6285
+
6286
+ .bead-tooltip-excerpt.markdown-body {
6287
+ white-space: normal;
6288
+ font-size: 12px;
6289
+ max-height: 200px;
6290
+ overflow-y: auto;
6291
+ }
6292
+ .bead-tooltip-excerpt.markdown-body p {
6293
+ margin: 0.3em 0;
6294
+ }
6295
+ .bead-tooltip-excerpt.markdown-body pre {
6296
+ font-size: 11px;
6297
+ max-height: 120px;
6298
+ overflow: auto;
6299
+ }
6300
+
6251
6301
  .workspace-run-card .workspace-card-root,
6252
6302
  .workspace-run-card .workspace-card-name {
6253
6303
  font-family: var(--font-mono);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -65,6 +65,7 @@
65
65
  "@xterm/addon-fit": "^0.11.0",
66
66
  "@xterm/addon-search": "^0.16.0",
67
67
  "@xterm/xterm": "^6.0.0",
68
+ "dompurify": "^3.4.5",
68
69
  "express": "^5.2.1",
69
70
  "lit-html": "^3.3.1",
70
71
  "lucide": "^0.577.0",
@@ -40,7 +40,7 @@ function transformIssue(issue, deps) {
40
40
  };
41
41
  }
42
42
 
43
- async function enrichWithDeps(issues, dbPath) {
43
+ export async function enrichIssuesWithDeps(issues, dbPath) {
44
44
  if (issues.length === 0) return [];
45
45
  const detailed = await runBd(['show', ...issues.map((i) => i.id)], dbPath);
46
46
  const detailMap = new Map(detailed.map((d) => [d.id, d]));
@@ -54,13 +54,21 @@ export function dbExists(beadsDb) {
54
54
  return existsSync(beadsDb);
55
55
  }
56
56
 
57
+ export async function listIssuesShallow(beadsDb) {
58
+ try {
59
+ return await runBd(['list', '--limit', '0'], beadsDb);
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
57
65
  export async function listIssues(beadsDb) {
58
66
  try {
59
67
  const issues = await runBd(['list', '--limit', '0'], beadsDb);
60
- // Must await here — without it, an enrichWithDeps rejection (e.g. bd show
68
+ // Must await here — without it, an enrichIssuesWithDeps rejection (e.g. bd show
61
69
  // SIGTERM under daemon contention) escapes the try/catch and propagates
62
70
  // to the WS handler as an unhandled rejection, crashing Node.
63
- return await enrichWithDeps(issues, beadsDb);
71
+ return await enrichIssuesWithDeps(issues, beadsDb);
64
72
  } catch {
65
73
  return [];
66
74
  }
@@ -72,7 +80,7 @@ export async function listIssuesByLabel(beadsDb, label) {
72
80
  ['list', '--label-any', label, '--all', '--limit', '0'],
73
81
  beadsDb,
74
82
  );
75
- return await enrichWithDeps(issues, beadsDb);
83
+ return await enrichIssuesWithDeps(issues, beadsDb);
76
84
  };
77
85
  try {
78
86
  return await attempt();
@@ -6,7 +6,11 @@
6
6
 
7
7
  import { existsSync, statSync, unwatchFile, watch, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
- import { countIssuesByRunLabel, listIssues } from './beads-reader.js';
9
+ import {
10
+ countIssuesByRunLabel,
11
+ enrichIssuesWithDeps,
12
+ listIssuesShallow,
13
+ } from './beads-reader.js';
10
14
 
11
15
  const BEADS_DEBOUNCE_MS = 500;
12
16
  const BEADS_POLL_MS = 2000;
@@ -34,19 +38,73 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
34
38
  let lastPayloadJson = null;
35
39
  let lastSelfReadWalStat = null;
36
40
  let latestCounts = {};
41
+ // In-flight guard + trailing coalesce. The refresh body spawns several `bd`
42
+ // subprocesses (listIssuesShallow, then enrichIssuesWithDeps +
43
+ // countIssuesByRunLabel when the fingerprint changes) that, on a large beads
44
+ // db, take seconds. The debounce only collapses scheduling — once the async body
45
+ // is awaiting, a fresh db/WAL event would otherwise start an overlapping
46
+ // refresh and pile up bd processes unbounded. Allow at most one refresh in
47
+ // flight; events arriving mid-refresh collapse into a single trailing pass.
48
+ let refreshing = false;
49
+ let refreshPending = false;
50
+ let lastListFingerprint = null;
51
+
52
+ function computeFingerprint(issues) {
53
+ const sorted = [...issues].sort((a, b) => (a.id < b.id ? -1 : 1));
54
+ return JSON.stringify(
55
+ sorted.map((i) => ({
56
+ id: i.id,
57
+ status: i.status,
58
+ priority: i.priority,
59
+ title: i.title,
60
+ updated_at: i.updated_at,
61
+ // dependency_count/dependent_count come free in `bd list` (no `bd show`).
62
+ // They catch dependency-edge changes (`bd dep add/remove`) that don't
63
+ // bump an issue's own updated_at — without them the fingerprint would
64
+ // bail and leave depends_on/blocked_by (and blocked badges) stale.
65
+ dependency_count: i.dependency_count,
66
+ dependent_count: i.dependent_count,
67
+ })),
68
+ );
69
+ }
70
+
71
+ function recordWalStat() {
72
+ try {
73
+ const s = statSync(beadsWalPath);
74
+ lastSelfReadWalStat = { mtimeMs: s.mtimeMs, size: s.size };
75
+ } catch {
76
+ lastSelfReadWalStat = null;
77
+ }
78
+ }
37
79
 
38
80
  function scheduleBeadsRefresh() {
39
81
  if (BEADS_REFRESH_TIMER) clearTimeout(BEADS_REFRESH_TIMER);
40
- BEADS_REFRESH_TIMER = setTimeout(async () => {
41
- BEADS_REFRESH_TIMER = null;
42
- try {
43
- const [issues, counts] = await Promise.all([
44
- listIssues(beadsDbPath),
45
- countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
46
- ]);
47
- latestCounts = counts;
48
- const payloadJson = JSON.stringify({ issues, counts });
49
- if (payloadJson === lastPayloadJson) return;
82
+ BEADS_REFRESH_TIMER = setTimeout(runBeadsRefresh, BEADS_DEBOUNCE_MS);
83
+ }
84
+
85
+ async function runBeadsRefresh() {
86
+ BEADS_REFRESH_TIMER = null;
87
+ if (refreshing) {
88
+ refreshPending = true;
89
+ return;
90
+ }
91
+ refreshing = true;
92
+ try {
93
+ const shallowIssues = await listIssuesShallow(beadsDbPath);
94
+ const fingerprint = computeFingerprint(shallowIssues);
95
+ if (fingerprint === lastListFingerprint) {
96
+ recordWalStat();
97
+ return;
98
+ }
99
+ lastListFingerprint = fingerprint;
100
+
101
+ const [issues, counts] = await Promise.all([
102
+ enrichIssuesWithDeps(shallowIssues, beadsDbPath),
103
+ countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
104
+ ]);
105
+ latestCounts = counts;
106
+ const payloadJson = JSON.stringify({ issues, counts });
107
+ if (payloadJson !== lastPayloadJson) {
50
108
  lastPayloadJson = payloadJson;
51
109
  broadcaster.broadcast(
52
110
  'beads-update',
@@ -58,16 +116,17 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
58
116
  },
59
117
  projectId,
60
118
  );
61
- try {
62
- const s = statSync(beadsWalPath);
63
- lastSelfReadWalStat = { mtimeMs: s.mtimeMs, size: s.size };
64
- } catch {
65
- lastSelfReadWalStat = null;
66
- }
67
- } catch {
68
- /* ignore */
69
119
  }
70
- }, BEADS_DEBOUNCE_MS);
120
+ recordWalStat();
121
+ } catch {
122
+ /* ignore */
123
+ } finally {
124
+ refreshing = false;
125
+ if (refreshPending) {
126
+ refreshPending = false;
127
+ scheduleBeadsRefresh();
128
+ }
129
+ }
71
130
  }
72
131
 
73
132
  if (existsSync(beadsDir)) {
@@ -116,7 +175,11 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
116
175
  return { getBeadsDbPath, getLatestCounts, destroy };
117
176
  }
118
177
 
119
- const FALLBACK_TTL_MS = 5000;
178
+ // Throttle window for the cold-path bead-count read (used by REST /runs and chat
179
+ // when no live watcher is warm). The read costs seconds on a large db, so a short
180
+ // TTL lets repeated /runs across many projects re-spawn `bd` too often. Counts are
181
+ // advisory and the live watcher keeps a viewed project fresh, so 30s is ample.
182
+ const FALLBACK_TTL_MS = 30000;
120
183
  /** @type {Map<string, { ts: number, counts: object }>} */
121
184
  const fallbackCache = new Map();
122
185
  /** @type {Map<string, Promise<object>>} */
@@ -163,3 +226,50 @@ export async function resolveBeadsCounts(wset) {
163
226
  fallbackInflight.set(key, promise);
164
227
  return promise;
165
228
  }
229
+
230
+ /**
231
+ * Non-blocking variant of {@link resolveBeadsCounts}: returns whatever counts
232
+ * are already available (the live watcher cache, or a fresh TTL-cached fallback)
233
+ * WITHOUT ever awaiting the cold `bd` query. When nothing is cached it kicks off
234
+ * a background refresh (reusing the same TTL cache + in-flight dedup) so a later
235
+ * caller gets real data, and returns {} immediately.
236
+ *
237
+ * Used by the REST `/runs` endpoint: a cold `bd show` on a large beads db
238
+ * (worca-cc has hundreds of issues — the cold read takes ~10s) would otherwise
239
+ * block the run list and, fanned out across every project, stall the whole UI.
240
+ * Bead counts on run cards are advisory and arrive via the `beads-update`
241
+ * broadcast once warm; the web run-detail does not use them at all.
242
+ *
243
+ * @param {{ projectId?: string, worcaDir?: string, beadsWatcher?: { getLatestCounts: () => object } | null }} [wset]
244
+ * @returns {Record<string, { total: number, done: number }>}
245
+ */
246
+ export function peekBeadsCounts(wset) {
247
+ if (!wset || !wset.worcaDir) return {};
248
+
249
+ const live = wset.beadsWatcher?.getLatestCounts();
250
+ if (live && Object.keys(live).length > 0) return live;
251
+
252
+ const key = wset.projectId ?? wset.worcaDir;
253
+ const cached = fallbackCache.get(key);
254
+ if (cached && Date.now() - cached.ts < FALLBACK_TTL_MS) return cached.counts;
255
+
256
+ // Cold cache: warm it in the background (dedup'd), but never block the caller.
257
+ if (!fallbackInflight.has(key)) {
258
+ const dbPath = beadsDbPathFor(wset.worcaDir);
259
+ if (existsSync(dbPath)) {
260
+ const promise = (async () => {
261
+ try {
262
+ const counts = await countIssuesByRunLabel(dbPath);
263
+ fallbackCache.set(key, { ts: Date.now(), counts });
264
+ return counts;
265
+ } catch {
266
+ return {};
267
+ } finally {
268
+ fallbackInflight.delete(key);
269
+ }
270
+ })();
271
+ fallbackInflight.set(key, promise);
272
+ }
273
+ }
274
+ return {};
275
+ }
@@ -13,7 +13,7 @@ import { fleetRunsDir, workspaceRunsDir } from './paths.js';
13
13
  import { readProjects, synthesizeDefaultProject } from './project-registry.js';
14
14
  import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
15
15
  import { readProjectWorcaVersion } from './worca-setup.js';
16
- import { resolveBeadsCounts } from './ws-beads-watcher.js';
16
+ import { peekBeadsCounts } from './ws-beads-watcher.js';
17
17
  import { createBroadcaster } from './ws-broadcaster.js';
18
18
  import { createClientManager } from './ws-client-manager.js';
19
19
  import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
@@ -331,8 +331,10 @@ export function attachWsServer(httpServer, config) {
331
331
  return null;
332
332
  }
333
333
 
334
+ // Non-blocking: returns cached/live counts and warms cold caches in the
335
+ // background. The REST /runs endpoint must never block on a slow `bd` read.
334
336
  function getBeadsCounts(projectId) {
335
- return resolveBeadsCounts(watcherSets.get(projectId));
337
+ return peekBeadsCounts(watcherSets.get(projectId));
336
338
  }
337
339
 
338
340
  return {