@worca/ui 0.31.0 → 0.33.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
 
@@ -5642,6 +5651,36 @@ sl-tooltip.bead-tooltip::part(body) {
5642
5651
  gap: 4px;
5643
5652
  }
5644
5653
 
5654
+ /* Workspace plan upload (existing mode) */
5655
+ .workspace-plan-upload {
5656
+ display: flex;
5657
+ flex-direction: column;
5658
+ gap: 8px;
5659
+ }
5660
+ .workspace-plan-advanced {
5661
+ font-size: 13px;
5662
+ }
5663
+ .workspace-plan-advanced sl-input {
5664
+ margin-top: 4px;
5665
+ }
5666
+
5667
+ /* Per-project plan rows (per-repo mode) */
5668
+ .per-project-plans {
5669
+ display: flex;
5670
+ flex-direction: column;
5671
+ gap: 6px;
5672
+ }
5673
+ .per-project-plan-row {
5674
+ display: flex;
5675
+ align-items: center;
5676
+ gap: 8px;
5677
+ }
5678
+ .per-project-plan-name {
5679
+ min-width: 100px;
5680
+ font-size: 13px;
5681
+ font-weight: 500;
5682
+ }
5683
+
5645
5684
  /* Base-branch validation states */
5646
5685
  .base-branch-validating {
5647
5686
  display: flex;
@@ -6248,6 +6287,62 @@ sl-dialog.markdown-dialog::part(body) {
6248
6287
  text-align: left;
6249
6288
  }
6250
6289
 
6290
+ /* --- Markdown context overrides --- */
6291
+
6292
+ .markdown-inline.markdown-body {
6293
+ display: inline;
6294
+ font-size: inherit;
6295
+ line-height: inherit;
6296
+ }
6297
+ .markdown-inline.markdown-body p {
6298
+ display: inline;
6299
+ margin: 0;
6300
+ }
6301
+ .markdown-inline.markdown-body code {
6302
+ font-size: 0.85em;
6303
+ }
6304
+
6305
+ .agent-prompt-content .markdown-body {
6306
+ white-space: normal;
6307
+ font-size: 12px;
6308
+ line-height: 1.5;
6309
+ }
6310
+ .agent-prompt-content .markdown-body h1,
6311
+ .agent-prompt-content .markdown-body h2,
6312
+ .agent-prompt-content .markdown-body h3 {
6313
+ margin: 0.8em 0 0.3em;
6314
+ }
6315
+
6316
+ .bead-tooltip-excerpt.markdown-body {
6317
+ white-space: normal;
6318
+ font-size: 12px;
6319
+ max-height: 200px;
6320
+ overflow-y: auto;
6321
+ /* Inherit the tooltip's (light) text color instead of --fg, which is the
6322
+ main-panel foreground and renders dark-on-dark inside the tooltip. */
6323
+ color: inherit;
6324
+ }
6325
+ .bead-tooltip-excerpt.markdown-body p {
6326
+ margin: 0.3em 0;
6327
+ }
6328
+ .bead-tooltip-excerpt.markdown-body pre {
6329
+ font-size: 11px;
6330
+ max-height: 120px;
6331
+ overflow: auto;
6332
+ }
6333
+ /* The base markdown code/blockquote/link colors assume the light main panel;
6334
+ re-tint them for the dark tooltip surface so they stay legible. */
6335
+ .bead-tooltip-excerpt.markdown-body code,
6336
+ .bead-tooltip-excerpt.markdown-body pre {
6337
+ background: rgba(255, 255, 255, 0.1);
6338
+ border-color: rgba(255, 255, 255, 0.15);
6339
+ }
6340
+ .bead-tooltip-excerpt.markdown-body blockquote {
6341
+ color: inherit;
6342
+ opacity: 0.8;
6343
+ border-left-color: rgba(255, 255, 255, 0.3);
6344
+ }
6345
+
6251
6346
  .workspace-run-card .workspace-card-root,
6252
6347
  .workspace-run-card .workspace-card-name {
6253
6348
  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.33.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",
package/server/app.js CHANGED
@@ -65,6 +65,39 @@ function runWorcaCleanupSubprocess(flag, id) {
65
65
  });
66
66
  }
67
67
 
68
+ export function buildWorkspaceArgs(workspace_root, workspace_id, manifest) {
69
+ const args = [
70
+ '-m',
71
+ 'worca.scripts.run_workspace',
72
+ workspace_root,
73
+ '--workspace-id',
74
+ workspace_id,
75
+ ];
76
+ if (manifest.work_request?.source) {
77
+ args.push('--source', manifest.work_request.source);
78
+ } else {
79
+ args.push('--prompt', manifest.work_request?.description ?? '');
80
+ }
81
+ if (manifest.branch_template) {
82
+ args.push('--branch', manifest.branch_template);
83
+ }
84
+ if (manifest.skip_integration) args.push('--skip-integration');
85
+ if (manifest.skip_planning) args.push('--skip-planning');
86
+ if (manifest.max_parallel) {
87
+ args.push('--max-parallel', String(manifest.max_parallel));
88
+ }
89
+ for (const p of manifest.guide?.paths || []) {
90
+ args.push('--guide', p);
91
+ }
92
+ if (manifest.workspace_plan_path) {
93
+ args.push('--workspace-plan', manifest.workspace_plan_path);
94
+ }
95
+ for (const [name, path] of Object.entries(manifest.project_plans || {})) {
96
+ args.push('--project-plan', `${name}=${path}`);
97
+ }
98
+ return args;
99
+ }
100
+
68
101
  export function createApp(options = {}) {
69
102
  const app = express();
70
103
  const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
@@ -733,29 +766,7 @@ export function createApp(options = {}) {
733
766
  child.unref();
734
767
  return;
735
768
  }
736
- const args = [
737
- '-m',
738
- 'worca.scripts.run_workspace',
739
- workspace_root,
740
- '--workspace-id',
741
- workspace_id,
742
- ];
743
- if (manifest.work_request?.source) {
744
- args.push('--source', manifest.work_request.source);
745
- } else {
746
- args.push('--prompt', manifest.work_request?.description ?? '');
747
- }
748
- if (manifest.branch_template) {
749
- args.push('--branch', manifest.branch_template);
750
- }
751
- if (manifest.skip_integration) args.push('--skip-integration');
752
- if (manifest.skip_planning) args.push('--skip-planning');
753
- if (manifest.max_parallel) {
754
- args.push('--max-parallel', String(manifest.max_parallel));
755
- }
756
- for (const p of manifest.guide?.paths || []) {
757
- args.push('--guide', p);
758
- }
769
+ const args = buildWorkspaceArgs(workspace_root, workspace_id, manifest);
759
770
  const child = spawn('python3', args, {
760
771
  detached: true,
761
772
  stdio: 'ignore',
@@ -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();
@@ -453,6 +453,32 @@ function renderWorkspacePlanFailed(envelope) {
453
453
  return mdMsg(parts.join('\n'), 'error');
454
454
  }
455
455
 
456
+ function renderWorkspacePlanLoaded(envelope) {
457
+ const p = envelope.payload ?? {};
458
+ const parts = [`📋 **Workspace plan loaded:** ${workspaceTitle(envelope)}`];
459
+ parts.push(
460
+ ` **Mode:** ${p.mode ?? '?'} (${p.project_count ?? '?'} project${p.project_count === 1 ? '' : 's'})`,
461
+ );
462
+ return mdMsg(parts.join('\n'), 'info');
463
+ }
464
+
465
+ function renderWorkspacePlanPartial(envelope) {
466
+ const p = envelope.payload ?? {};
467
+ const uncovered = Array.isArray(p.uncovered_projects)
468
+ ? p.uncovered_projects
469
+ : [];
470
+ const parts = [
471
+ `⚠ **Workspace plan partial coverage:** ${workspaceTitle(envelope)}`,
472
+ ];
473
+ parts.push(
474
+ ` **Mode:** ${p.mode ?? '?'} — ${uncovered.length} uncovered project${uncovered.length === 1 ? '' : 's'}`,
475
+ );
476
+ if (uncovered.length > 0) {
477
+ parts.push(` **Uncovered:** ${uncovered.join(', ')}`);
478
+ }
479
+ return mdMsg(parts.join('\n'), 'warning');
480
+ }
481
+
456
482
  function renderWorkspaceTierStarted(envelope) {
457
483
  const p = envelope.payload ?? {};
458
484
  const projects = Array.isArray(p.projects) ? p.projects : [];
@@ -570,6 +596,8 @@ export const OPT_IN_RENDERERS = {
570
596
  'workspace.plan.started': renderWorkspacePlanStarted,
571
597
  'workspace.plan.completed': renderWorkspacePlanCompleted,
572
598
  'workspace.plan.failed': renderWorkspacePlanFailed,
599
+ 'workspace.plan.loaded': renderWorkspacePlanLoaded,
600
+ 'workspace.plan.partial': renderWorkspacePlanPartial,
573
601
  'workspace.tier.started': renderWorkspaceTierStarted,
574
602
  'workspace.tier.completed': renderWorkspaceTierCompleted,
575
603
  'workspace.integration_test.started': renderWorkspaceIntegrationStarted,
@@ -29,6 +29,8 @@ import {
29
29
  } from './paths.js';
30
30
 
31
31
  const GUIDE_CAP_BYTES_DEFAULT = 64 * 1024;
32
+ const PROJECT_PLAN_CAP_BYTES = 256 * 1024;
33
+ const WORKSPACE_PLAN_CAP_BYTES = 256 * 1024;
32
34
 
33
35
  const WS_ID_RE = /^ws_\d{12}_[0-9a-f]{1,32}$/;
34
36
 
@@ -557,6 +559,105 @@ function parseMultipart(body, contentType) {
557
559
  return parts;
558
560
  }
559
561
 
562
+ // ─── plan strategy resolution ─────────────────────────────────────────────
563
+
564
+ /**
565
+ * Map a plan_mode to the manifest fields it implies and validate
566
+ * mode-specific preconditions.
567
+ *
568
+ * @param {string} plan_mode - 'master' | 'existing' | 'per-repo' | 'independent'
569
+ * @param {string|null} workspace_plan_path
570
+ * @param {Object|null} project_plans - { projectName: filePath, ... }
571
+ * @param {Object} ws - parsed workspace.json (needs ws.projects)
572
+ * @returns {{ ok: true, fields: object } | { ok: false, status: number, error: string }}
573
+ */
574
+ export function _resolvePlanStrategy(
575
+ plan_mode,
576
+ workspace_plan_path,
577
+ project_plans,
578
+ ws,
579
+ ) {
580
+ const mode = plan_mode || 'master';
581
+ const projectNames = new Set((ws.projects ?? []).map((p) => p.name));
582
+
583
+ if (mode === 'existing') {
584
+ if (!workspace_plan_path) {
585
+ return {
586
+ ok: false,
587
+ status: 400,
588
+ error:
589
+ 'existing mode requires a workspace plan (upload or server-side path)',
590
+ };
591
+ }
592
+ return {
593
+ ok: true,
594
+ fields: {
595
+ plan_mode: 'existing',
596
+ workspace_plan_path,
597
+ project_plans: null,
598
+ skip_planning: false,
599
+ },
600
+ };
601
+ }
602
+
603
+ if (mode === 'per-repo') {
604
+ const plans = project_plans ?? {};
605
+ const planKeys = Object.keys(plans);
606
+ if (planKeys.length === 0) {
607
+ return {
608
+ ok: false,
609
+ status: 400,
610
+ error: 'per-repo mode requires at least one project plan',
611
+ };
612
+ }
613
+ const unknown = planKeys.filter((name) => !projectNames.has(name));
614
+ if (unknown.length > 0) {
615
+ return {
616
+ ok: false,
617
+ status: 422,
618
+ error: `Unknown project(s) in per-repo plans: ${unknown.join(', ')}`,
619
+ };
620
+ }
621
+ return {
622
+ ok: true,
623
+ fields: {
624
+ plan_mode: 'per-repo',
625
+ workspace_plan_path: null,
626
+ project_plans: plans,
627
+ skip_planning: false,
628
+ },
629
+ };
630
+ }
631
+
632
+ if (mode === 'independent') {
633
+ return {
634
+ ok: true,
635
+ fields: {
636
+ plan_mode: 'independent',
637
+ workspace_plan_path: null,
638
+ project_plans: null,
639
+ skip_planning: true,
640
+ },
641
+ };
642
+ }
643
+
644
+ // master (default) — ignore any stray plan inputs. master mode always runs
645
+ // the master planner; the Python CLI infers its mode from which flags are
646
+ // present, so passing --workspace-plan/--project-plan through here would
647
+ // silently run the job as existing/per-repo while the manifest (and badge)
648
+ // still claimed "master". Null them out to keep declared mode and actual
649
+ // behavior in lockstep.
650
+ return {
651
+ ok: true,
652
+ fields: {
653
+ plan_mode: 'master',
654
+ workspace_plan_path: null,
655
+ project_plans: null,
656
+ skip_planning: false,
657
+ },
658
+ };
659
+ }
660
+
560
661
  // ─── default injectables ──────────────────────────────────────────────────
561
662
 
562
663
  function defaultValidateBaseBranch(repoPath, branch) {
@@ -929,6 +1030,8 @@ export function createWorkspaceRouter({
929
1030
 
930
1031
  let fields = {};
931
1032
  const guideFiles = [];
1033
+ let workspacePlanFileData = null;
1034
+ const projectPlanFiles = {};
932
1035
 
933
1036
  if (isMultipart) {
934
1037
  const rawBody = await readRawBody(req);
@@ -939,7 +1042,21 @@ export function createWorkspaceRouter({
939
1042
  .json({ ok: false, error: 'Failed to parse multipart body' });
940
1043
  }
941
1044
  for (const part of parts) {
942
- if (part.filename != null) {
1045
+ if (part.name === 'workspace_plan_file' && part.filename != null) {
1046
+ workspacePlanFileData = {
1047
+ filename: part.filename,
1048
+ content: part.content,
1049
+ };
1050
+ } else if (
1051
+ part.name?.startsWith('project_plan_') &&
1052
+ part.filename != null
1053
+ ) {
1054
+ const projectName = part.name.slice('project_plan_'.length);
1055
+ projectPlanFiles[projectName] = {
1056
+ filename: part.filename,
1057
+ content: part.content,
1058
+ };
1059
+ } else if (part.filename != null) {
943
1060
  guideFiles.push({ filename: part.filename, content: part.content });
944
1061
  } else if (part.name) {
945
1062
  fields[part.name] = part.content.toString('utf8');
@@ -1035,6 +1152,57 @@ export function createWorkspaceRouter({
1035
1152
  guideEntry = { paths, bytes: totalBytes, filenames, uploaded: true };
1036
1153
  }
1037
1154
 
1155
+ // ── workspace plan file upload / server-side path ──────────────
1156
+ let workspacePlanPath = null;
1157
+ if (workspacePlanFileData) {
1158
+ if (workspacePlanFileData.content.length > WORKSPACE_PLAN_CAP_BYTES) {
1159
+ return res.status(400).json({
1160
+ ok: false,
1161
+ error: 'Workspace plan exceeds 256 KB limit',
1162
+ });
1163
+ }
1164
+ workspacePlanPath = join(wsRunDir, 'workspace-plan.json');
1165
+ writeFileSync(workspacePlanPath, workspacePlanFileData.content);
1166
+ } else if (fields.workspace_plan) {
1167
+ if (!existsSync(fields.workspace_plan)) {
1168
+ return res.status(400).json({
1169
+ ok: false,
1170
+ error: `workspace_plan path not found: ${fields.workspace_plan}`,
1171
+ });
1172
+ }
1173
+ workspacePlanPath = fields.workspace_plan;
1174
+ }
1175
+
1176
+ // ── per-project plan file uploads ──────────────────────────────
1177
+ const projectPlans = {};
1178
+ for (const [name, file] of Object.entries(projectPlanFiles)) {
1179
+ if (file.content.length > PROJECT_PLAN_CAP_BYTES) {
1180
+ return res.status(400).json({
1181
+ ok: false,
1182
+ error: `Project plan for "${name}" exceeds 256 KB limit`,
1183
+ });
1184
+ }
1185
+ const safeName = sanitizeFilename(name);
1186
+ const plansDir = join(wsRunDir, 'plans');
1187
+ mkdirSync(plansDir, { recursive: true });
1188
+ const planPath = join(plansDir, `${safeName}.md`);
1189
+ writeFileSync(planPath, file.content);
1190
+ projectPlans[name] = planPath;
1191
+ }
1192
+
1193
+ // ── resolve plan strategy + validate ──────────────────────────
1194
+ const planResult = _resolvePlanStrategy(
1195
+ plan_mode,
1196
+ workspacePlanPath,
1197
+ Object.keys(projectPlans).length > 0 ? projectPlans : null,
1198
+ ws,
1199
+ );
1200
+ if (!planResult.ok) {
1201
+ return res
1202
+ .status(planResult.status)
1203
+ .json({ ok: false, error: planResult.error });
1204
+ }
1205
+
1038
1206
  const tiers = computeTiers(ws.projects);
1039
1207
  const dagTiers = tiers.map((projects, i) => ({
1040
1208
  tier: i,
@@ -1054,10 +1222,10 @@ export function createWorkspaceRouter({
1054
1222
  source: source ?? null,
1055
1223
  },
1056
1224
  guide: guideEntry,
1225
+ ...planResult.fields,
1057
1226
  branch_template: branch_template ?? 'workspace/{slug}/{project}',
1058
1227
  max_parallel: Number(max_parallel) || 5,
1059
1228
  skip_integration: false,
1060
- skip_planning: plan_mode === 'skip',
1061
1229
  status: 'planning',
1062
1230
  halt_reason: null,
1063
1231
  dag: { tiers: dagTiers },