@worca/ui 0.26.0 → 0.27.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
@@ -2031,7 +2031,7 @@ sl-input [slot="prefix"] {
2031
2031
 
2032
2032
  .pricing-model-name {
2033
2033
  font-weight: 500;
2034
- text-transform: capitalize;
2034
+ text-transform: uppercase;
2035
2035
  }
2036
2036
 
2037
2037
  .pricing-table sl-input {
@@ -5084,6 +5084,56 @@ sl-tooltip.bead-tooltip::part(body) {
5084
5084
  border-color: var(--status-failed, #ef4444);
5085
5085
  }
5086
5086
 
5087
+ .rename-model-input.is-invalid::part(base) {
5088
+ border-color: var(--status-failed, #ef4444);
5089
+ }
5090
+
5091
+ .rename-model-error {
5092
+ color: var(--status-failed, #ef4444);
5093
+ font-size: 12px;
5094
+ margin: 0.4rem 0 0;
5095
+ }
5096
+
5097
+ .model-env-details::part(base) {
5098
+ border: none;
5099
+ background: transparent;
5100
+ box-shadow: none;
5101
+ }
5102
+
5103
+ .model-env-details::part(header) {
5104
+ padding: 0.25rem 0.4rem;
5105
+ border-radius: 6px;
5106
+ }
5107
+
5108
+ .model-env-details::part(header):hover {
5109
+ background: var(--hover, rgba(0, 0, 0, 0.04));
5110
+ }
5111
+
5112
+ .model-env-details::part(content) {
5113
+ padding: 0.5rem 0 0;
5114
+ }
5115
+
5116
+ .model-env-summary {
5117
+ display: flex;
5118
+ align-items: center;
5119
+ justify-content: space-between;
5120
+ width: 100%;
5121
+ gap: 0.75rem;
5122
+ }
5123
+
5124
+ .model-env-invalid-chip {
5125
+ color: var(--status-failed, #ef4444);
5126
+ font-weight: 600;
5127
+ font-size: 11px;
5128
+ text-transform: uppercase;
5129
+ letter-spacing: 0.5px;
5130
+ }
5131
+
5132
+ .model-env-details.has-invalid::part(header) {
5133
+ border-left: 3px solid var(--status-failed, #ef4444);
5134
+ padding-left: 0.5rem;
5135
+ }
5136
+
5087
5137
  .model-env-value::part(input) {
5088
5138
  font-size: 12px;
5089
5139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -59,14 +59,21 @@ export async function listIssues(beadsDb) {
59
59
  }
60
60
 
61
61
  export async function listIssuesByLabel(beadsDb, label) {
62
- try {
62
+ const attempt = async () => {
63
63
  const issues = await runBd(
64
64
  ['list', '--label-any', label, '--all', '--limit', '0'],
65
65
  beadsDb,
66
66
  );
67
67
  return await enrichWithDeps(issues, beadsDb);
68
+ };
69
+ try {
70
+ return await attempt();
68
71
  } catch {
69
- return [];
72
+ // bd/SQLite contention during active runs is usually sub-second — one
73
+ // retry covers the observed window. If it still fails, propagate so the
74
+ // WS handler can return an error rather than masquerading as "no beads."
75
+ await new Promise((r) => setTimeout(r, 150));
76
+ return await attempt();
70
77
  }
71
78
  }
72
79
 
@@ -115,8 +115,13 @@ export function createModelEnvRouter({ settingsPath: staticPath } = {}) {
115
115
 
116
116
  let baseChanged = false;
117
117
  if (resolvedId) {
118
- // Prefer string form when there's no other metadata keeps JSON minimal.
119
- const nextBaseEntry = resolvedId;
118
+ // When env exists in local, base MUST use the object form `{id}` so
119
+ // deepMerge({id}, {env}) preserves the id. With the string form,
120
+ // deepMerge would see a non-object base and discard it, dropping the id
121
+ // entirely — the bug behind empty Model ID after Duplicate/Paste.
122
+ // String form stays the default when there's no env, to keep JSON minimal.
123
+ const hasEnv = Object.keys(envIn).length > 0;
124
+ const nextBaseEntry = hasEnv ? { id: resolvedId } : resolvedId;
120
125
  if (JSON.stringify(baseEntry) !== JSON.stringify(nextBaseEntry)) {
121
126
  base.worca.models[model] = nextBaseEntry;
122
127
  baseChanged = true;
@@ -478,7 +478,18 @@ export function createProjectScopedRoutes({
478
478
  });
479
479
  }
480
480
 
481
- const validation = validateSettingsPayload(body);
481
+ let existingForValidation = {};
482
+ try {
483
+ if (existsSync(settingsPath)) {
484
+ existingForValidation = JSON.parse(readFileSync(settingsPath, 'utf8'));
485
+ }
486
+ } catch {
487
+ existingForValidation = {};
488
+ }
489
+
490
+ const validation = validateSettingsPayload(body, {
491
+ existing: existingForValidation,
492
+ });
482
493
  if (!validation.valid) {
483
494
  return res.status(400).json({
484
495
  error: {
@@ -558,12 +569,43 @@ export function createProjectScopedRoutes({
558
569
  atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
559
570
  }
560
571
 
572
+ // STEP 3a: strip shadowed worca keys from settings.local.json. Local is
573
+ // deep-merged over base on read, so a stale `worca.<key>` copy in local
574
+ // would resurrect after the user saves a new value. `models` is excluded
575
+ // because its env-portion lives in local by design (see model-env-routes).
576
+ const lp = localPathFor(settingsPath);
577
+ let localChanged = false;
578
+ const localForPrune = readLocalSettings(settingsPath);
579
+ if (
580
+ body.worca &&
581
+ typeof body.worca === 'object' &&
582
+ localForPrune.worca &&
583
+ typeof localForPrune.worca === 'object'
584
+ ) {
585
+ for (const key of Object.keys(body.worca)) {
586
+ if (key === 'models') continue;
587
+ if (key in localForPrune.worca) {
588
+ delete localForPrune.worca[key];
589
+ localChanged = true;
590
+ }
591
+ }
592
+ if (localChanged && Object.keys(localForPrune.worca).length === 0) {
593
+ delete localForPrune.worca;
594
+ }
595
+ }
596
+
561
597
  if (body.permissions !== undefined) {
562
- const lp = localPathFor(settingsPath);
563
- const local = readLocalSettings(settingsPath);
564
- local.permissions = body.permissions;
598
+ localForPrune.permissions = body.permissions;
599
+ localChanged = true;
600
+ }
601
+
602
+ if (localChanged) {
565
603
  mkdirSync(dirname(lp), { recursive: true });
566
- writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
604
+ writeFileSync(
605
+ lp,
606
+ `${JSON.stringify(localForPrune, null, 2)}\n`,
607
+ 'utf8',
608
+ );
567
609
  }
568
610
 
569
611
  const merged = readMergedSettings(settingsPath);
@@ -36,8 +36,12 @@ const VALID_PRICING_FIELDS = [
36
36
  'cache_read_per_mtok',
37
37
  ];
38
38
 
39
- export function validateSettingsPayload(body) {
39
+ export function validateSettingsPayload(body, options = {}) {
40
40
  const details = [];
41
+ const existingWorca =
42
+ options.existing && typeof options.existing === 'object'
43
+ ? options.existing.worca || {}
44
+ : {};
41
45
 
42
46
  if (body.worca !== undefined) {
43
47
  if (
@@ -49,7 +53,16 @@ export function validateSettingsPayload(body) {
49
53
  return { valid: false, details };
50
54
  }
51
55
  const w = body.worca;
52
- const validModels = deriveValidModels(w);
56
+ // Sections like agents/pricing reference model keys that may live in another
57
+ // section saved earlier. Merge persisted models with body-supplied models so
58
+ // a single-section save (e.g. agents-only) doesn't reject custom models.
59
+ const mergedModels = {
60
+ ...(existingWorca.models && typeof existingWorca.models === 'object'
61
+ ? existingWorca.models
62
+ : {}),
63
+ ...(w.models && typeof w.models === 'object' ? w.models : {}),
64
+ };
65
+ const validModels = deriveValidModels({ models: mergedModels });
53
66
 
54
67
  // agents
55
68
  if (w.agents !== undefined) {
@@ -682,14 +682,32 @@ export function createMessageRouter({
682
682
  ws.send(JSON.stringify(makeOk(req, { issues: [], runId })));
683
683
  return;
684
684
  }
685
- const issues = await listIssuesByLabel(beadsDbPath, `run:${runId}`);
686
- console.log(
687
- '[list-beads-by-run] runId=%s count=%d statuses=%o',
688
- runId,
689
- issues.length,
690
- issues.map((i) => i.status),
691
- );
692
- ws.send(JSON.stringify(makeOk(req, { issues, runId })));
685
+ try {
686
+ const issues = await listIssuesByLabel(beadsDbPath, `run:${runId}`);
687
+ console.log(
688
+ '[list-beads-by-run] runId=%s count=%d statuses=%o',
689
+ runId,
690
+ issues.length,
691
+ issues.map((i) => i.status),
692
+ );
693
+ ws.send(JSON.stringify(makeOk(req, { issues, runId })));
694
+ } catch (err) {
695
+ // Don't return empty issues on failure — the UI would treat that as
696
+ // "all beads deleted" and tear down the open <sl-details> panel. Let
697
+ // the client keep its last-known-good state until the next poll.
698
+ console.warn(
699
+ `[list-beads-by-run] runId=${runId} failed: ${err?.message || err}`,
700
+ );
701
+ ws.send(
702
+ JSON.stringify(
703
+ makeError(
704
+ req,
705
+ 'beads_unavailable',
706
+ `bd query failed: ${err?.message || err}`,
707
+ ),
708
+ ),
709
+ );
710
+ }
693
711
  return;
694
712
  }
695
713