@worca/ui 0.8.1 → 0.9.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
@@ -714,7 +714,7 @@ h1, h2, h3, h4, h5, h6 {
714
714
 
715
715
  .stage-node.status-in-progress .stage-icon {
716
716
  border-color: var(--status-in-progress);
717
- box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.15);
717
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
718
718
  }
719
719
 
720
720
  .stage-node.status-completed .stage-icon {
@@ -1539,6 +1539,11 @@ sl-details.log-history-panel::part(content) {
1539
1539
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1540
1540
  gap: 14px;
1541
1541
  }
1542
+ /* Worca Versions panels — 50% wider min column (200 → 300) to fit longer
1543
+ version strings (e.g. RCs + local-repo dirty markers) without wrapping */
1544
+ .settings-grid--versions {
1545
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
1546
+ }
1542
1547
 
1543
1548
  .settings-switches {
1544
1549
  display: flex;
@@ -1648,8 +1653,8 @@ sl-details.log-history-panel::part(content) {
1648
1653
 
1649
1654
  .settings-dispatch-row {
1650
1655
  display: grid;
1651
- grid-template-columns: 120px 1fr;
1652
- align-items: center;
1656
+ grid-template-columns: 120px 1fr auto;
1657
+ align-items: start;
1653
1658
  gap: 12px;
1654
1659
  }
1655
1660
 
@@ -1658,6 +1663,97 @@ sl-details.log-history-panel::part(content) {
1658
1663
  font-weight: 500;
1659
1664
  color: var(--fg);
1660
1665
  text-transform: capitalize;
1666
+ padding-top: 6px;
1667
+ }
1668
+
1669
+ /* Tag input component */
1670
+ .dispatch-tag-input-wrapper {
1671
+ position: relative;
1672
+ }
1673
+
1674
+ .dispatch-tag-input {
1675
+ display: flex;
1676
+ flex-wrap: wrap;
1677
+ gap: 4px;
1678
+ align-items: center;
1679
+ padding: 4px 8px;
1680
+ border: 1px solid var(--sl-color-neutral-300);
1681
+ border-radius: var(--sl-border-radius-medium);
1682
+ min-height: 32px;
1683
+ cursor: text;
1684
+ background: var(--sl-color-neutral-0);
1685
+ }
1686
+
1687
+ .dispatch-tag-input:focus-within {
1688
+ border-color: var(--sl-color-primary-500);
1689
+ box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-primary-200);
1690
+ }
1691
+
1692
+ .dispatch-tag-input-field {
1693
+ border: none;
1694
+ outline: none;
1695
+ flex: 1;
1696
+ min-width: 60px;
1697
+ font-size: var(--sl-font-size-small);
1698
+ background: transparent;
1699
+ color: var(--fg);
1700
+ }
1701
+
1702
+ .dispatch-suggestions {
1703
+ position: absolute;
1704
+ z-index: 100;
1705
+ top: calc(100% + 2px);
1706
+ left: 0;
1707
+ right: 0;
1708
+ background: var(--sl-color-neutral-0);
1709
+ border: 1px solid var(--sl-color-neutral-200);
1710
+ border-radius: var(--sl-border-radius-medium);
1711
+ box-shadow: var(--sl-shadow-large);
1712
+ max-height: 200px;
1713
+ overflow-y: auto;
1714
+ }
1715
+
1716
+ .dispatch-suggestions .item {
1717
+ padding: 6px 12px;
1718
+ cursor: pointer;
1719
+ font-size: var(--sl-font-size-small);
1720
+ display: flex;
1721
+ justify-content: space-between;
1722
+ align-items: center;
1723
+ }
1724
+
1725
+ .dispatch-suggestions .item:hover,
1726
+ .dispatch-suggestions .item.active {
1727
+ background: var(--sl-color-primary-50);
1728
+ }
1729
+
1730
+ .dispatch-suggestions .item.denied {
1731
+ opacity: 0.5;
1732
+ text-decoration: line-through;
1733
+ cursor: not-allowed;
1734
+ }
1735
+
1736
+ .dispatch-suggestions .item-label {
1737
+ font-size: 11px;
1738
+ color: var(--sl-color-neutral-500);
1739
+ }
1740
+
1741
+ .dispatch-suggestions .group-label {
1742
+ padding: 4px 12px;
1743
+ font-size: 11px;
1744
+ color: var(--sl-color-neutral-500);
1745
+ text-transform: uppercase;
1746
+ letter-spacing: 0.05em;
1747
+ background: var(--sl-color-neutral-50);
1748
+ }
1749
+
1750
+ .dispatch-reset-btn {
1751
+ padding-top: 2px;
1752
+ }
1753
+
1754
+ .dispatch-reset-placeholder {
1755
+ width: 28px;
1756
+ display: inline-block;
1661
1757
  }
1662
1758
 
1663
1759
  /* Permissions list */
@@ -1823,27 +1919,22 @@ sl-input.pricing-input::part(input) {
1823
1919
  color: var(--muted);
1824
1920
  }
1825
1921
 
1826
- .iteration-trigger {
1827
- display: inline-flex;
1922
+ /* --- Iteration Tags Row (trigger, outcome, classification, subagents) --- */
1923
+ .iteration-tags-row {
1924
+ display: flex;
1925
+ flex-wrap: wrap;
1828
1926
  align-items: center;
1829
- gap: 4px;
1830
- padding: 2px 8px;
1831
- border-radius: 4px;
1832
- font-size: 11px;
1833
- font-weight: 500;
1834
- background: var(--bg-tertiary);
1927
+ gap: 6px;
1928
+ margin-top: 6px;
1929
+ font-size: 13px;
1835
1930
  }
1836
1931
 
1837
- .iteration-outcome {
1838
- display: inline-flex;
1839
- align-items: center;
1840
- gap: 4px;
1841
- font-weight: 500;
1932
+ .iteration-tags-sep {
1933
+ color: var(--fg);
1934
+ opacity: 0.7;
1935
+ margin: 0 2px;
1842
1936
  }
1843
1937
 
1844
- .iteration-outcome.success { color: var(--status-completed); }
1845
- .iteration-outcome.failure { color: var(--status-error); }
1846
-
1847
1938
  /* --- Agent Prompt Section --- */
1848
1939
  sl-details.agent-prompt-section {
1849
1940
  margin-top: 12px;
@@ -4143,3 +4234,87 @@ sl-tooltip.bead-tooltip::part(body) {
4143
4234
  text-overflow: ellipsis;
4144
4235
  }
4145
4236
 
4237
+ /* ─── Add Project / Worca Setup dialog shared styles ─────────────────── */
4238
+ .dialog-meta-row {
4239
+ display: flex;
4240
+ align-items: center;
4241
+ gap: 0.5rem;
4242
+ flex-wrap: wrap;
4243
+ font-size: 0.85rem;
4244
+ color: var(--sl-color-neutral-700);
4245
+ margin-bottom: 0.5rem;
4246
+ }
4247
+
4248
+ .dialog-meta-row .sep {
4249
+ color: var(--sl-color-neutral-400);
4250
+ }
4251
+
4252
+ .dialog-meta-row .spacer {
4253
+ flex: 1;
4254
+ }
4255
+
4256
+ .dialog-section-heading {
4257
+ margin: 0.75rem 0 0.5rem;
4258
+ font-size: 0.95rem;
4259
+ font-weight: 600;
4260
+ color: var(--sl-color-neutral-900);
4261
+ }
4262
+
4263
+ .dialog-list {
4264
+ max-height: 300px;
4265
+ overflow-y: auto;
4266
+ border: 1px solid var(--sl-color-neutral-200);
4267
+ border-radius: 4px;
4268
+ padding: 6px 8px;
4269
+ }
4270
+
4271
+ .dialog-list-row {
4272
+ display: flex;
4273
+ align-items: center;
4274
+ gap: 8px;
4275
+ padding: 4px 0;
4276
+ min-height: 28px;
4277
+ }
4278
+
4279
+ .dialog-list-row + .dialog-list-row {
4280
+ border-top: 1px solid var(--sl-color-neutral-100);
4281
+ }
4282
+
4283
+ .dialog-list-row.is-terminal .dialog-list-row-name {
4284
+ opacity: 0.6;
4285
+ }
4286
+
4287
+ .dialog-list-row-icon {
4288
+ flex: 0 0 auto;
4289
+ width: 20px;
4290
+ display: inline-flex;
4291
+ align-items: center;
4292
+ justify-content: center;
4293
+ font-weight: 700;
4294
+ }
4295
+
4296
+ .dialog-list-row-icon.is-success {
4297
+ color: var(--sl-color-success-600);
4298
+ }
4299
+
4300
+ .dialog-list-row-icon.is-failed {
4301
+ color: var(--sl-color-danger-600);
4302
+ }
4303
+
4304
+ .dialog-list-row-name {
4305
+ flex: 0 0 auto;
4306
+ }
4307
+
4308
+ .dialog-list-row-error {
4309
+ color: var(--sl-color-danger-600);
4310
+ font-size: 0.8rem;
4311
+ margin-left: 0.25rem;
4312
+ }
4313
+
4314
+ .dialog-footer {
4315
+ display: flex;
4316
+ justify-content: center;
4317
+ gap: 0.75rem;
4318
+ width: 100%;
4319
+ }
4320
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
package/server/app.js CHANGED
@@ -2,17 +2,21 @@
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { createHmac, randomUUID } from 'node:crypto';
5
- import { basename, dirname, join } from 'node:path';
5
+ import { existsSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { basename, dirname, isAbsolute, join } from 'node:path';
6
8
  import { fileURLToPath } from 'node:url';
7
9
  import express from 'express';
8
10
 
9
11
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
10
12
  import { ProcessManager } from './process-manager.js';
13
+ import { scanDirectory } from './project-registry.js';
11
14
  import {
12
15
  createProjectRoutes,
13
16
  createProjectScopedRoutes,
14
17
  projectResolver,
15
18
  } from './project-routes.js';
19
+ import { discoverSubagents } from './subagents-discovery.js';
16
20
  import { getVersionInfo } from './versions.js';
17
21
  import { createInbox } from './webhook-inbox.js';
18
22
 
@@ -20,6 +24,9 @@ export function createApp(options = {}) {
20
24
  const app = express();
21
25
  const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
22
26
  const { settingsPath, worcaDir, projectRoot, prefsDir } = options;
27
+ // subagentDirs is a test-injection seam; production calls omit it and we
28
+ // resolve from homedir() + projectRoot.
29
+ const subagentDirs = options.subagentDirs || null;
23
30
 
24
31
  app.use(express.json());
25
32
 
@@ -92,6 +99,31 @@ export function createApp(options = {}) {
92
99
 
93
100
  // ─── Unique routes (not in project-scoped router) ──────────────────────
94
101
 
102
+ // GET /api/subagents — list discoverable subagent types for the dispatch editor.
103
+ // Walks ~/.claude/agents/ (user-global), ~/.claude/plugins/cache/
104
+ // (plugin-cached), and the active project's .claude/agents/ (in single-project
105
+ // mode). Tests inject alternate dirs via createApp({ subagentDirs: {...} }).
106
+ app.get('/api/subagents', (_req, res) => {
107
+ try {
108
+ const userDir =
109
+ subagentDirs?.userDir ?? join(homedir(), '.claude', 'agents');
110
+ const pluginCacheDir =
111
+ subagentDirs?.pluginCacheDir ??
112
+ join(homedir(), '.claude', 'plugins', 'cache');
113
+ const projectAgentsDir =
114
+ subagentDirs?.projectAgentsDir ??
115
+ (projectRoot ? join(projectRoot, '.claude', 'agents') : undefined);
116
+ const subagents = discoverSubagents({
117
+ userDir,
118
+ pluginCacheDir,
119
+ projectAgentsDir,
120
+ });
121
+ res.json({ ok: true, subagents });
122
+ } catch (err) {
123
+ res.status(500).json({ ok: false, error: err.message });
124
+ }
125
+ });
126
+
95
127
  // GET /api/beads/issues
96
128
  app.get('/api/beads/issues', (_req, res) => {
97
129
  if (!worcaDir)
@@ -404,6 +436,30 @@ export function createApp(options = {}) {
404
436
  }
405
437
  });
406
438
 
439
+ // POST /api/scan-directory — scan parent folder for immediate git subdirectories
440
+ app.post('/api/scan-directory', async (req, res) => {
441
+ const { path: dirPath } = req.body || {};
442
+ if (!dirPath || typeof dirPath !== 'string') {
443
+ return res.status(400).json({ ok: false, error: 'path is required' });
444
+ }
445
+ if (!isAbsolute(dirPath)) {
446
+ return res
447
+ .status(400)
448
+ .json({ ok: false, error: 'path must be absolute' });
449
+ }
450
+ if (!existsSync(dirPath)) {
451
+ return res
452
+ .status(400)
453
+ .json({ ok: false, error: `directory does not exist: ${dirPath}` });
454
+ }
455
+ try {
456
+ const subfolders = await scanDirectory(dirPath);
457
+ res.json({ ok: true, subfolders });
458
+ } catch (err) {
459
+ res.status(500).json({ ok: false, error: err.message });
460
+ }
461
+ });
462
+
407
463
  // GET /api/versions — installed + registry version info
408
464
  app.get('/api/versions', async (req, res) => {
409
465
  const force = req.query.force === '1';
@@ -423,7 +479,7 @@ export function createApp(options = {}) {
423
479
  app.use(
424
480
  '/api/projects/:projectId',
425
481
  projectResolver({ prefsDir, projectRoot }),
426
- createProjectScopedRoutes(),
482
+ createProjectScopedRoutes({ prefsDir }),
427
483
  );
428
484
  }
429
485
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Dispatch-event aggregator — reads pipeline.hook.dispatch_{allowed,blocked}
3
+ * events from a run's events.jsonl and assigns them to the matching iteration
4
+ * in status.json by timestamp range.
5
+ *
6
+ * Works for both live and completed runs because it reads only persisted data
7
+ * (events.jsonl is append-only and survives the pipeline process exiting).
8
+ *
9
+ * Aggregation: events are deduplicated per iteration by (type, subagent_type).
10
+ * A `count` field tracks how many times the same (type, subagent_type) fired
11
+ * in that iteration. The `reason` from the first occurrence is kept (reasons
12
+ * for the same key are deterministic — derived from the denylist/rule check).
13
+ *
14
+ * Output shape per iteration:
15
+ * dispatch_events: [
16
+ * { type, subagent_type, reason?, count }
17
+ * ]
18
+ */
19
+
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+
22
+ const DISPATCH_EVENT_TYPES = new Set([
23
+ 'pipeline.hook.dispatch_allowed',
24
+ 'pipeline.hook.dispatch_blocked',
25
+ ]);
26
+
27
+ /**
28
+ * Parse events.jsonl and return only the dispatch events, with normalised shape.
29
+ * Malformed lines are silently skipped so a corrupt event doesn't break the run view.
30
+ *
31
+ * @param {string} eventsPath — absolute path to events.jsonl
32
+ * @returns {Array<{type, subagent_type, reason?, timestamp}>}
33
+ */
34
+ export function readDispatchEventsFromJsonl(eventsPath) {
35
+ if (!eventsPath || !existsSync(eventsPath)) return [];
36
+ let content;
37
+ try {
38
+ content = readFileSync(eventsPath, 'utf8');
39
+ } catch {
40
+ return [];
41
+ }
42
+ const out = [];
43
+ for (const line of content.split('\n')) {
44
+ if (!line.trim()) continue;
45
+ let e;
46
+ try {
47
+ e = JSON.parse(line);
48
+ } catch {
49
+ continue;
50
+ }
51
+ if (!DISPATCH_EVENT_TYPES.has(e.event_type)) continue;
52
+ const payload = e.payload || {};
53
+ if (!payload.subagent_type) continue;
54
+ out.push({
55
+ type: e.event_type,
56
+ subagent_type: payload.subagent_type,
57
+ reason: payload.reason,
58
+ timestamp: e.timestamp,
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Given a list of dispatch events and a stages map from status.json, return
66
+ * a new stages map where each iteration that overlaps an event's timestamp
67
+ * is enriched with a `dispatch_events` array (deduplicated by type+subagent_type
68
+ * with a count).
69
+ *
70
+ * Non-destructive: input stages object is shallow-copied; iterations get new
71
+ * objects with the extra field. Existing iteration fields are preserved.
72
+ *
73
+ * @param {Array<{type, subagent_type, reason?, timestamp}>} events
74
+ * @param {object} stages — status.stages
75
+ * @returns {object} enriched stages
76
+ */
77
+ export function assignEventsToIterations(events, stages) {
78
+ if (!stages || typeof stages !== 'object') return stages;
79
+ if (!events || events.length === 0) {
80
+ // Nothing to add — return input unchanged to avoid unnecessary allocation.
81
+ return stages;
82
+ }
83
+
84
+ // Bucket events into iterations first, then aggregate per bucket.
85
+ // Bucket key: `${stageKey}|${iterationNumber}`.
86
+ const buckets = new Map();
87
+
88
+ for (const ev of events) {
89
+ if (!ev.timestamp) continue;
90
+ const eventTime = Date.parse(ev.timestamp);
91
+ if (Number.isNaN(eventTime)) continue;
92
+
93
+ let matched = false;
94
+ for (const [stageKey, stage] of Object.entries(stages)) {
95
+ const iterations = stage?.iterations;
96
+ if (!Array.isArray(iterations)) continue;
97
+ for (const iter of iterations) {
98
+ const start = iter.started_at ? Date.parse(iter.started_at) : NaN;
99
+ if (Number.isNaN(start)) continue;
100
+ // If the iteration hasn't completed, treat end as +infinity so live events land here.
101
+ const end = iter.completed_at
102
+ ? Date.parse(iter.completed_at)
103
+ : Number.POSITIVE_INFINITY;
104
+ if (eventTime >= start && eventTime <= end) {
105
+ const key = `${stageKey}|${iter.number}`;
106
+ if (!buckets.has(key)) buckets.set(key, []);
107
+ buckets.get(key).push(ev);
108
+ matched = true;
109
+ break;
110
+ }
111
+ }
112
+ if (matched) break;
113
+ }
114
+ // If no iteration matched, the event is silently dropped — it falls
115
+ // outside any recorded iteration window (e.g. during stage transitions).
116
+ }
117
+
118
+ if (buckets.size === 0) return stages;
119
+
120
+ // Aggregate each bucket and build the enriched stages map.
121
+ const enrichedStages = { ...stages };
122
+ for (const [key, bucketEvents] of buckets) {
123
+ const [stageKey, iterNumStr] = key.split('|');
124
+ const iterNum = Number(iterNumStr);
125
+ const stage = enrichedStages[stageKey];
126
+ if (!stage) continue;
127
+ const aggregated = aggregate(bucketEvents);
128
+ const newIterations = stage.iterations.map((iter) =>
129
+ iter.number === iterNum ? { ...iter, dispatch_events: aggregated } : iter,
130
+ );
131
+ enrichedStages[stageKey] = { ...stage, iterations: newIterations };
132
+ }
133
+ return enrichedStages;
134
+ }
135
+
136
+ /**
137
+ * Deduplicate an array of dispatch events by (type, subagent_type) and count
138
+ * occurrences. First reason wins for blocked events.
139
+ *
140
+ * @param {Array<{type, subagent_type, reason?}>} events
141
+ * @returns {Array<{type, subagent_type, reason?, count}>}
142
+ */
143
+ function aggregate(events) {
144
+ const map = new Map();
145
+ for (const ev of events) {
146
+ const key = `${ev.type}|${ev.subagent_type}`;
147
+ const existing = map.get(key);
148
+ if (existing) {
149
+ existing.count += 1;
150
+ } else {
151
+ const entry = {
152
+ type: ev.type,
153
+ subagent_type: ev.subagent_type,
154
+ count: 1,
155
+ };
156
+ if (ev.reason) entry.reason = ev.reason;
157
+ map.set(key, entry);
158
+ }
159
+ }
160
+ return [...map.values()];
161
+ }
@@ -10,7 +10,9 @@ import {
10
10
  unlinkSync,
11
11
  writeFileSync,
12
12
  } from 'node:fs';
13
+ import { readdir } from 'node:fs/promises';
13
14
  import { basename, isAbsolute, join } from 'node:path';
15
+ import { checkWorcaInstalled, readProjectWorcaVersion } from './worca-setup.js';
14
16
 
15
17
  export const SLUG_RE = /^[a-z0-9_-]{1,64}$/i;
16
18
  const DEFAULT_MAX_PROJECTS = 20;
@@ -24,6 +26,7 @@ export function slugify(name) {
24
26
  .toLowerCase()
25
27
  .replace(/[^a-z0-9_-]/g, '-')
26
28
  .replace(/-{2,}/g, '-')
29
+ .replace(/^-+|-+$/g, '')
27
30
  .slice(0, 64);
28
31
  }
29
32
 
@@ -130,6 +133,40 @@ export function synthesizeDefaultProject(projectRoot) {
130
133
  };
131
134
  }
132
135
 
136
+ const SCAN_MAX_RESULTS = 200;
137
+
138
+ /**
139
+ * Scan a directory for immediate child folders that contain a .git subdirectory.
140
+ * Skips dotfiles (names starting with ".") and "node_modules".
141
+ * Returns entries sorted alphabetically by name, capped at SCAN_MAX_RESULTS.
142
+ *
143
+ * @param {string} dirPath - Absolute path to the parent directory
144
+ * @returns {Promise<{ name: string, path: string }[]>}
145
+ */
146
+ export async function scanDirectory(dirPath) {
147
+ const entries = await readdir(dirPath, { withFileTypes: true });
148
+ const results = [];
149
+ for (const entry of entries) {
150
+ if (!entry.isDirectory()) continue;
151
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
152
+ const childPath = join(dirPath, entry.name);
153
+ if (existsSync(join(childPath, '.git'))) {
154
+ const installed = checkWorcaInstalled(childPath);
155
+ const worcaVersion = installed
156
+ ? readProjectWorcaVersion(childPath)
157
+ : null;
158
+ results.push({
159
+ name: entry.name,
160
+ path: childPath,
161
+ installed,
162
+ worcaVersion,
163
+ });
164
+ if (results.length >= SCAN_MAX_RESULTS) break;
165
+ }
166
+ }
167
+ return results.sort((a, b) => a.name.localeCompare(b.name));
168
+ }
169
+
133
170
  /**
134
171
  * Read max projects from {prefsDir}/config.json. Defaults to 20.
135
172
  */