@worca/app 1.2.0-rc.3 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/app",
3
- "version": "1.2.0-rc.3",
3
+ "version": "1.2.0",
4
4
  "description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
package/ui/public/app.js CHANGED
@@ -296,6 +296,10 @@ const el = {
296
296
  chatSettingsMsg: $('#chatSettingsMsg'),
297
297
  settingsTabs: $('#settings-tabs'),
298
298
 
299
+ // About (Settings card): read-only app identity, painted from /api/settings
300
+ aboutVersion: $('#aboutVersion'),
301
+ aboutRepoLink: $('#aboutRepoLink'),
302
+
299
303
  // Guardrails view
300
304
  guardrailsList: $('#guardrails-list'),
301
305
  guardrailsMsg: $('#guardrails-msg'),
@@ -7754,7 +7758,7 @@ function setFormMsg(text, kind) {
7754
7758
  }
7755
7759
 
7756
7760
  // ---------------------------------------------------------------------------
7757
- // Settings view: the machine-wide Worca CC root folder + the projects root
7761
+ // Settings view: the machine-wide Worca root folder + the projects root
7758
7762
  // (§5.1) whose CLAUDE.md / .claude/skills / .mcp.json every pipeline agent sees.
7759
7763
  // ---------------------------------------------------------------------------
7760
7764
  function setSettingsMsg(text, kind) {
@@ -7782,6 +7786,22 @@ function paintSettings(data) {
7782
7786
  // fallback so an older/partial response still fills the placeholder.
7783
7787
  const projectsRootFallback = (data) => data.projectsRootDefault || data.default || '';
7784
7788
 
7789
+ // About card: painted from the `app` block of GET /api/settings. A missing or
7790
+ // malformed payload leaves the static markup (including target/rel) untouched.
7791
+ function paintAbout(info) {
7792
+ if (!info) return;
7793
+ if (el.aboutVersion && info.version) {
7794
+ el.aboutVersion.textContent = info.version;
7795
+ // Links the version to its release tag; a missing/malformed URL leaves the
7796
+ // anchor href-less, i.e. plain text.
7797
+ if (typeof info.releaseUrl === 'string' && info.releaseUrl) el.aboutVersion.href = info.releaseUrl;
7798
+ }
7799
+ if (el.aboutRepoLink && typeof info.repoUrl === 'string' && info.repoUrl) {
7800
+ el.aboutRepoLink.href = info.repoUrl;
7801
+ el.aboutRepoLink.textContent = info.repoUrl.replace(/^https?:\/\//, '');
7802
+ }
7803
+ }
7804
+
7785
7805
  async function loadSettings() {
7786
7806
  if (!el.settingsRoot) return;
7787
7807
  try {
@@ -7789,6 +7809,7 @@ async function loadSettings() {
7789
7809
  const data = await safeJson(res);
7790
7810
  if (!res.ok) { setSettingsMsg(data.error || `HTTP ${res.status}`, 'err'); return; }
7791
7811
  paintSettings(data);
7812
+ paintAbout(data.app);
7792
7813
  paintBudgetSettings(data);
7793
7814
  paintAskSettings(data);
7794
7815
  paintDebugSpawnSettings(data);
@@ -85,6 +85,22 @@ export function shortcutLabel(win) {
85
85
  return /mac|iphone|ipad|ipod/i.test(platform) ? '⌘K' : 'Ctrl K';
86
86
  }
87
87
 
88
+ /**
89
+ * Sheet geometry shared with style.css: .ask-dock{padding:0 28px 26px} and
90
+ * .ask-sheet{width:min(782px,100%);height:min(669px,calc(100% - 20px))}. The
91
+ * user's size is clamped to [minW×minH, dock inner box]. The floor IS the
92
+ * stylesheet default: the sheet grows from what it always was and never shrinks
93
+ * below it, so every layout the fixed-size sheet was designed around (the
94
+ * non-wrapping composer row, the popovers written in 100vh terms) still holds.
95
+ * Border-box px throughout — the sheet has no padding.
96
+ */
97
+ export const ASK_SHEET_SIZE = Object.freeze({
98
+ defaultW: 782, defaultH: 669,
99
+ minW: 782, minH: 669,
100
+ dockPadX: 28, dockPadBottom: 26, topGap: 20,
101
+ });
102
+ const SIZE_KEY = 'worca-cc.ask.size';
103
+
88
104
  export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, loadMarkdown, hljsLoader, storage, raf, now }) {
89
105
  const storedPick = readStoredModel(); // hoisted declaration (defined below); null when nothing is stored
90
106
  const st = {
@@ -109,6 +125,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
109
125
  worktrees: [], // P4 §10: the chat's open worktrees (snapshot-fed)
110
126
  pinned: true,
111
127
  prevFocus: null,
128
+ size: readStoredSize(), // {w,h} the user's persisted sheet size (hoisted reader); null = stylesheet default
129
+ applied: null, // {w,h} the inline size currently on the sheet (clamped); null = default
130
+ drag: null, // the active resize gesture — see startResize()
112
131
  pendingFiles: [],
113
132
  sending: false,
114
133
  subscribedFor: null,
@@ -161,6 +180,32 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
161
180
  else storage.removeItem('worca-cc.ask.thread');
162
181
  } catch { /* ignore */ }
163
182
  }
183
+ /** The persisted sheet size, or null when nothing usable is stored. Clamped later, against a live dock. */
184
+ function readStoredSize() {
185
+ try {
186
+ const raw = storage.getItem(SIZE_KEY);
187
+ const v = raw ? JSON.parse(raw) : null;
188
+ if (v && Number.isFinite(v.w) && Number.isFinite(v.h)) return { w: Math.round(v.w), h: Math.round(v.h) };
189
+ } catch { /* storage unavailable */ }
190
+ return null;
191
+ }
192
+ function storeSize(size) {
193
+ try {
194
+ if (size) storage.setItem(SIZE_KEY, JSON.stringify({ w: size.w, h: size.h }));
195
+ else storage.removeItem(SIZE_KEY);
196
+ } catch { /* ignore */ }
197
+ }
198
+
199
+ /**
200
+ * Size the composer textarea to its content, one line minimum, 120px maximum.
201
+ * Bound to the input event; every programmatic write to el.input.value (the
202
+ * post-send clear, appendToComposer) must call it too, since assignment
203
+ * fires no input event and the box would keep the previous draft's height.
204
+ */
205
+ function fitInput() {
206
+ el.input.style.height = 'auto';
207
+ el.input.style.height = `${Math.min(el.input.scrollHeight || 0, 120)}px`;
208
+ }
164
209
 
165
210
  // ---- tiny DOM helpers -----------------------------------------------------
166
211
  function make(tag, className, text) {
@@ -257,10 +302,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
257
302
  el.jump.hidden = true;
258
303
  el.jump.addEventListener('click', jumpToLatest);
259
304
  sheet.appendChild(el.jump);
305
+ for (const edge of ['n', 'e', 'w', 'ne', 'nw']) sheet.appendChild(buildResizeHandle(edge));
260
306
  dock.appendChild(sheet);
261
307
  dock.appendChild(pill);
262
308
  el.pill = pill;
263
309
  el.sheet = sheet;
310
+ el.dock = dock; // measured by dockInner(); `root` is TDZ here
264
311
  return dock;
265
312
  }
266
313
 
@@ -430,6 +477,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
430
477
  // No provisional title from the prompt: the header keeps "Ask Worca" until
431
478
  // the ask-title frame lands (ask-model marks title dirty, flushExtra repaints).
432
479
  el.input.value = '';
480
+ fitInput(); // a programmatic clear fires no input event
433
481
  st.pendingFiles = [];
434
482
  renderChips();
435
483
  subscribe(id);
@@ -455,10 +503,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
455
503
  el.input.addEventListener('keydown', (e) => {
456
504
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
457
505
  });
458
- el.input.addEventListener('input', () => {
459
- el.input.style.height = 'auto';
460
- el.input.style.height = `${Math.min(el.input.scrollHeight || 0, 120)}px`;
461
- });
506
+ el.input.addEventListener('input', fitInput);
462
507
  wrap.appendChild(el.input);
463
508
 
464
509
  el.composerMsg = make('div', 'ask-composer-msg');
@@ -581,6 +626,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
581
626
  st.prevFocus = doc.activeElement;
582
627
  el.pill.hidden = true;
583
628
  el.sheet.hidden = false;
629
+ restoreSize(); // the sheet has a box now — clamp the stored size to the dock
584
630
  st.pinned = true;
585
631
  ensureFirstOpen();
586
632
  focusComposer();
@@ -598,8 +644,8 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
598
644
  openSheet(); // no-op when already open…
599
645
  const cur = el.input.value;
600
646
  el.input.value = cur ? `${cur.replace(/\s*$/, '')}\n${add}` : add;
601
- // …so the autosize listener and the focus have to be driven here.
602
- el.input.dispatchEvent(new win.Event('input'));
647
+ // …so the autosize and the focus have to be driven here.
648
+ fitInput();
603
649
  focusComposer();
604
650
  try { el.input.selectionStart = el.input.selectionEnd = el.input.value.length; } catch { /* jsdom */ }
605
651
  return true;
@@ -619,6 +665,180 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
619
665
 
620
666
  function toggleSheet() { (st.open ? closeSheet : openSheet)(); }
621
667
 
668
+ // ---- resize ---------------------------------------------------------------
669
+ // The sheet is a bottom-anchored, horizontally centred flex child of the dock,
670
+ // so a top-edge drag is a pure height change and a side drag grows the width
671
+ // symmetrically about the centre (each edge moves half the delta, which keeps
672
+ // the grabbed edge under the cursor). Sizes are border-box px applied as
673
+ // inline width/height, so the stylesheet's default rule stays byte-identical
674
+ // and its max-width/max-height backstop still bounds a stale inline size.
675
+ //
676
+ // A gesture ends on pointerup/pointercancel, and — like graph/composer.mjs —
677
+ // on window blur, lostpointercapture (the sheet was hidden, the capture was
678
+ // taken) and a pointermove that reports no button held (the release landed
679
+ // outside the window and never reached us). All of those COMMIT: a half-done
680
+ // resize is still a size the user chose, unlike a half-drawn wire. Escape is
681
+ // the one CANCEL: the pre-drag size comes back and nothing is stored. Only a
682
+ // gesture that actually moved persists, and only the axis it dragged — the
683
+ // other axis keeps the stored preference, so a click on a dock-clamped sheet
684
+ // can never write the clamp back over a larger preference.
685
+ function buildResizeHandle(edge) {
686
+ const h = make('div', `ask-resize ask-resize-${edge}`);
687
+ h.setAttribute('data-ask-resize', edge);
688
+ h.setAttribute('aria-hidden', 'true');
689
+ h.addEventListener('pointerdown', (e) => startResize(e, edge, h));
690
+ h.addEventListener('lostpointercapture', () => { if (st.drag && st.drag.handle === h) finishResize(); });
691
+ h.addEventListener('dblclick', (e) => { e.preventDefault(); resetSize(); });
692
+ return h;
693
+ }
694
+
695
+ /** The dock's content box in px; zeros when there is no layout (hidden, detached, jsdom). */
696
+ function dockInner() {
697
+ const d = el.dock;
698
+ const w = d ? d.clientWidth : 0;
699
+ const h = d ? d.clientHeight : 0;
700
+ return {
701
+ w: w > 0 ? w - 2 * ASK_SHEET_SIZE.dockPadX : 0,
702
+ h: h > 0 ? h - ASK_SHEET_SIZE.dockPadBottom - ASK_SHEET_SIZE.topGap : 0,
703
+ };
704
+ }
705
+
706
+ /** Clamp to [min, dock inner]. The dock bound wins when the two conflict (a narrow viewport). */
707
+ function clampSize(size) {
708
+ const inner = dockInner();
709
+ const maxW = inner.w > 0 ? inner.w : Infinity;
710
+ const maxH = inner.h > 0 ? inner.h : Infinity;
711
+ return {
712
+ w: Math.round(Math.min(Math.max(size.w, ASK_SHEET_SIZE.minW), maxW)),
713
+ h: Math.round(Math.min(Math.max(size.h, ASK_SHEET_SIZE.minH), maxH)),
714
+ };
715
+ }
716
+
717
+ /** Write the inline size, or clear it (null) so min(782px,100%) rules again. */
718
+ function applySize(size) {
719
+ if (size) {
720
+ el.sheet.style.width = `${size.w}px`;
721
+ el.sheet.style.height = `${size.h}px`;
722
+ } else {
723
+ el.sheet.style.removeProperty('width');
724
+ el.sheet.style.removeProperty('height');
725
+ }
726
+ st.applied = size;
727
+ }
728
+
729
+ /** Re-apply the persisted preference against the current dock — on open and on window resize. */
730
+ function restoreSize() {
731
+ applySize(st.size ? clampSize(st.size) : null);
732
+ }
733
+
734
+ /** The sheet's live border-box size; the applied/default size when there is no layout. */
735
+ function currentSize() {
736
+ const w = el.sheet.offsetWidth;
737
+ const h = el.sheet.offsetHeight;
738
+ if (w > 0 && h > 0) return { w, h };
739
+ return st.applied ? { w: st.applied.w, h: st.applied.h } : { w: ASK_SHEET_SIZE.defaultW, h: ASK_SHEET_SIZE.defaultH };
740
+ }
741
+
742
+ function startResize(e, edge, handle) {
743
+ if (st.drag || st.destroyed || (e.button != null && e.button !== 0)) return;
744
+ const start = currentSize();
745
+ st.drag = {
746
+ edge, handle, pointerId: e.pointerId, x: e.clientX, y: e.clientY, w: start.w, h: start.h,
747
+ moved: false, // set by the first pointermove that applies a size
748
+ before: st.applied ? { w: st.applied.w, h: st.applied.h } : null, // what Escape restores
749
+ };
750
+ handle.classList.add('is-active');
751
+ el.sheet.classList.add('is-resizing');
752
+ // Capture is a bonus, never a precondition (Chrome throws for a synthetic
753
+ // pointerId; jsdom has no such method) — the document listeners carry the
754
+ // gesture either way, exactly like graph/composer.mjs.
755
+ try { handle.setPointerCapture?.(e.pointerId); } catch { /* synthetic pointer */ }
756
+ doc.addEventListener('pointermove', onResizeMove);
757
+ doc.addEventListener('pointerup', onResizeEnd);
758
+ doc.addEventListener('pointercancel', onResizeEnd);
759
+ win.addEventListener('blur', onResizeBlur);
760
+ e.preventDefault(); // no text selection / focus steal mid-drag
761
+ }
762
+
763
+ function samePointer(g, e) {
764
+ return g.pointerId == null || e.pointerId == null || e.pointerId === g.pointerId;
765
+ }
766
+
767
+ function onResizeMove(e) {
768
+ const g = st.drag;
769
+ if (!g || !samePointer(g, e)) return;
770
+ if (e.buttons === 0) { finishResize(); return; } // the release never reached us
771
+ g.moved = true;
772
+ const dx = e.clientX - g.x;
773
+ const dy = e.clientY - g.y;
774
+ let w = g.w;
775
+ let h = g.h;
776
+ if (g.edge === 'w' || g.edge === 'nw') w = g.w - 2 * dx; // left edge: leftwards grows
777
+ if (g.edge === 'e' || g.edge === 'ne') w = g.w + 2 * dx; // right edge: rightwards grows
778
+ if (g.edge === 'n' || g.edge === 'ne' || g.edge === 'nw') h = g.h - dy; // top edge: upwards grows
779
+ applySize(clampSize({ w, h }));
780
+ }
781
+
782
+ function onResizeEnd(e) {
783
+ const g = st.drag;
784
+ if (!g || !samePointer(g, e)) return;
785
+ finishResize();
786
+ }
787
+
788
+ function onResizeBlur() { finishResize(); }
789
+
790
+ /** Idempotent; also run from destroy() so a mid-drag unmount leaves no document listeners. */
791
+ function finishResize() {
792
+ const g = endResize();
793
+ if (!g || !g.moved || !st.applied) return;
794
+ // Persist the dragged axis only; the other keeps the stored preference (or,
795
+ // with none stored, the size it had) — the clamp is never written back.
796
+ const movesW = g.edge !== 'n';
797
+ const movesH = g.edge !== 'e' && g.edge !== 'w';
798
+ const prev = st.size;
799
+ st.size = {
800
+ w: movesW || !prev ? st.applied.w : prev.w,
801
+ h: movesH || !prev ? st.applied.h : prev.h,
802
+ };
803
+ storeSize(st.size);
804
+ }
805
+
806
+ /** Escape: the pre-drag size comes back and nothing is stored. */
807
+ function cancelResize() {
808
+ const g = endResize();
809
+ if (!g) return;
810
+ applySize(g.before);
811
+ }
812
+
813
+ /** Tear the gesture down (listeners, classes, capture) and hand it back; null when none. */
814
+ function endResize() {
815
+ const g = st.drag;
816
+ if (!g) return null;
817
+ st.drag = null;
818
+ doc.removeEventListener('pointermove', onResizeMove);
819
+ doc.removeEventListener('pointerup', onResizeEnd);
820
+ doc.removeEventListener('pointercancel', onResizeEnd);
821
+ win.removeEventListener('blur', onResizeBlur);
822
+ g.handle.classList.remove('is-active');
823
+ el.sheet.classList.remove('is-resizing');
824
+ try { if (g.handle.hasPointerCapture?.(g.pointerId)) g.handle.releasePointerCapture(g.pointerId); } catch { /* already gone */ }
825
+ return g;
826
+ }
827
+
828
+ /** Double-click on any grip: back to the stylesheet default and forget the stored size. */
829
+ function resetSize() {
830
+ finishResize();
831
+ st.size = null;
832
+ applySize(null);
833
+ storeSize(null);
834
+ }
835
+
836
+ /** Window resize or dock resize (the rail toggling, the dock's slide): re-clamp — never under a held pointer. */
837
+ function onWinResize() {
838
+ if (st.destroyed || !st.open || !st.size || st.drag) return;
839
+ restoreSize();
840
+ }
841
+
622
842
  // ---- keyboard + pointer routing ------------------------------------------
623
843
  function containsNode(rootEl, t) { return !!(t && t.nodeType && rootEl.contains(t)); }
624
844
 
@@ -633,6 +853,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
633
853
 
634
854
  function onDocKeydown(e) {
635
855
  if (st.destroyed) return;
856
+ if (st.drag && e.key === 'Escape') { e.preventDefault(); cancelResize(); return; }
636
857
  if (isToggleCombo(e)) {
637
858
  if (e.repeat || e.isComposing) return;
638
859
  e.preventDefault();
@@ -2104,15 +2325,26 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2104
2325
  const root = buildRoot();
2105
2326
  doc.addEventListener('keydown', onDocKeydown, true);
2106
2327
  doc.addEventListener('pointerdown', onDocPointerdown, true);
2328
+ win.addEventListener('resize', onWinResize);
2329
+ // The rail collapsing changes the dock width by 222px with no window resize;
2330
+ // observe the dock itself (guarded: jsdom has no ResizeObserver — P5's idiom).
2331
+ let dockRo = null;
2332
+ if (typeof win.ResizeObserver === 'function') {
2333
+ dockRo = new win.ResizeObserver(onWinResize);
2334
+ dockRo.observe(el.dock);
2335
+ }
2107
2336
 
2108
2337
  function destroy() {
2109
2338
  if (st.destroyed) return;
2110
2339
  st.destroyed = true;
2340
+ finishResize(); // a mid-drag unmount leaves no document listeners
2111
2341
  closePopover({ focusTrigger: false });
2112
2342
  if (st.elapsedTimer) { clearInterval(st.elapsedTimer); st.elapsedTimer = null; }
2113
2343
  if (el.orb) el.orb.stop();
2114
2344
  doc.removeEventListener('keydown', onDocKeydown, true);
2115
2345
  doc.removeEventListener('pointerdown', onDocPointerdown, true);
2346
+ win.removeEventListener('resize', onWinResize);
2347
+ if (dockRo) { dockRo.disconnect(); dockRo = null; }
2116
2348
  root.remove();
2117
2349
  }
2118
2350
 
@@ -1024,12 +1024,12 @@
1024
1024
  <section class="card settings-card">
1025
1025
  <div class="field">
1026
1026
  <div class="label-row">
1027
- <label for="settingsRoot">Worca CC root folder</label>
1028
- <button type="button" class="info-tip" aria-label="About Worca CC root folder">i<span class="tip-content hidden">
1029
- The folder under which Worca CC keeps its data &mdash; a <code>.worca-cc/</code> directory holding the
1027
+ <label for="settingsRoot">Worca root folder</label>
1028
+ <button type="button" class="info-tip" aria-label="About Worca root folder">i<span class="tip-content hidden">
1029
+ The folder under which Worca keeps its data &mdash; a <code>.worca-cc/</code> directory holding the
1030
1030
  history store, the project list, and saved workflows. Leave empty to use the default.
1031
1031
  Changes apply to new runs immediately. Changing the root does <b>not</b> move existing
1032
- data: Worca CC starts a fresh <code>.worca-cc/</code> under the new folder; history under
1032
+ data: Worca starts a fresh <code>.worca-cc/</code> under the new folder; history under
1033
1033
  the old root is left in place.
1034
1034
  </span></button>
1035
1035
  </div>
@@ -1231,6 +1231,26 @@
1231
1231
  </div>
1232
1232
  <small class="hint" id="chatSettingsMsg"></small>
1233
1233
  </section>
1234
+
1235
+ <!-- About: intentionally no info-tip and no .hint (test/ui-settings-tooltips.test.mjs pins both). -->
1236
+ <section class="card settings-card" id="about-card">
1237
+ <div class="label-row">
1238
+ <h2>About</h2>
1239
+ </div>
1240
+ <div class="about-row">
1241
+ <span class="about-key">Version</span>
1242
+ <!-- href is painted from app.releaseUrl (the worca-app-v<version> tag);
1243
+ without one the anchor is plain text, so the placeholder never links. -->
1244
+ <a class="about-val about-version" id="aboutVersion"
1245
+ target="_blank" rel="noopener noreferrer">&mdash;</a>
1246
+ </div>
1247
+ <div class="about-row">
1248
+ <span class="about-key">Repository</span>
1249
+ <a class="about-val about-link" id="aboutRepoLink"
1250
+ href="https://github.com/SinishaDjukic/worca-cc"
1251
+ target="_blank" rel="noopener noreferrer">github.com/SinishaDjukic/worca-cc</a>
1252
+ </div>
1253
+ </section>
1234
1254
  </section>
1235
1255
  <!-- /pane general -->
1236
1256
 
@@ -1574,10 +1574,23 @@ fieldset.source legend{font-weight:600;font-size:13px;padding:0 6px;color:var(--
1574
1574
  .topnav button.active .topnav-amt{color:inherit;}
1575
1575
 
1576
1576
  /* ---------- Settings cards ---------- */
1577
- /* One width/centering/gap rule for all three Settings cards. */
1577
+ /* One width/centering/gap rule for every Settings card. */
1578
1578
  .settings-card{max-width:760px;padding:22px;margin-left:auto;margin-right:auto;}
1579
1579
  .settings-card + .settings-card{margin-top:18px;}
1580
1580
 
1581
+ /* About card rows. .about-val and .about-link share specificity on one anchor, so
1582
+ .about-link must stay AFTER .about-val (test/ui-settings-about.test.mjs pins it). */
1583
+ .about-row{display:flex;align-items:baseline;gap:10px;font-size:13px;}
1584
+ .about-row + .about-row{margin-top:10px;}
1585
+ .about-key{flex:0 0 96px;font-weight:600;color:var(--ink-2);}
1586
+ .about-val{color:var(--ink);min-width:0;}
1587
+ .about-version{font-family:var(--mono);}
1588
+ /* Once painted with a release-tag href the version reads as a link. */
1589
+ .about-version[href]{color:var(--green-ink);text-decoration:none;}
1590
+ .about-version[href]:hover{text-decoration:underline;}
1591
+ .about-link{color:var(--green-ink);font-weight:600;text-decoration:none;overflow-wrap:anywhere;}
1592
+ .about-link:hover{text-decoration:underline;}
1593
+
1581
1594
  /* ---------- Budget readout (Settings) ---------- */
1582
1595
  /* Section gap before the limit fields (the disclaimer hint that used to sit
1583
1596
  here moved into the card heading's ⓘ tooltip). */
@@ -2860,7 +2873,8 @@ body.rail-collapsed .ask-dock{left:76px;}
2860
2873
  font-family:var(--mono);font-weight:400;font-size:10.5px;}
2861
2874
 
2862
2875
  .ask-sheet{pointer-events:auto;position:relative;display:flex;flex-direction:column;overflow:hidden;
2863
- width:min(782px,100%);height:min(669px,calc(100% - 20px));background:var(--panel);
2876
+ width:min(782px,100%);height:min(669px,calc(100% - 20px));max-width:100%;max-height:calc(100% - 20px);
2877
+ background:var(--panel);
2864
2878
  border:1px solid var(--line-2);border-radius:var(--r-card);
2865
2879
  box-shadow:0 18px 60px rgba(25,25,27,.14),0 2px 6px rgba(25,25,27,.06);
2866
2880
  animation:wr-rise .26s cubic-bezier(.2,.7,.3,1) both;}
@@ -2957,7 +2971,7 @@ body.rail-collapsed .ask-dock{left:76px;}
2957
2971
  .ask-agent-log-t{flex:0 0 auto;color:var(--seq);}
2958
2972
  .ask-agent-log-text{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;}
2959
2973
 
2960
- .ask-answer{max-width:92%;font-size:13.5px;line-height:1.72;color:var(--ink);}
2974
+ .ask-answer{max-width:100%;font-size:13.5px;line-height:1.72;color:var(--ink);}
2961
2975
  .ask-answer-plain{white-space:pre-wrap;overflow-wrap:anywhere;}
2962
2976
  .ask-md :is(h1,h2,h3,h4,h5,h6){margin:14px 0 6px;font-size:13.5px;font-weight:600;color:var(--ink);}
2963
2977
  .ask-md h1{font-size:15px;}
@@ -3145,6 +3159,41 @@ body.rail-collapsed .ask-dock{left:76px;}
3145
3159
  .ask-wt-row .ask-runinfo-sub.ask-wt-path{cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px;}
3146
3160
  .ask-wt-row .ask-thread-trash{flex:none;}
3147
3161
 
3162
+ /* Resize grips (spec §10.3 addendum). Invisible edge zones INSIDE the sheet —
3163
+ overflow:hidden would clip anything outside it — that only change the cursor;
3164
+ a 2px inset line appears on hover and stays while dragging (.is-active). No
3165
+ bottom grip: the sheet is bottom-anchored. ask-panel.mjs applies the user's
3166
+ size as inline width/height and clamps it to the dock; the max-* pair on
3167
+ .ask-sheet above is the backstop that keeps a stale inline size inside the
3168
+ dock (the rail collapsing changes the dock width without a window resize).
3169
+ The floor (JS-only, the stylesheet default 782×669: the sheet grows, never
3170
+ shrinks) is not a CSS min-* on purpose — that would overflow narrow viewports
3171
+ that width:min(782px,100%) handles today.
3172
+ Corners: the sheet clips at its rounded corner (inner radius = --r-card minus
3173
+ the 1px border), so a square corner grip's straight inset lines fall almost
3174
+ entirely outside the clip and only a ~3px sliver ever shows. Each corner is
3175
+ therefore exactly one radius wide and carries the same outer radius, so its
3176
+ inset shadow bends along the arc, flush with the border like the straight
3177
+ edges; the straight grips start where the arc ends (--ask-corner). */
3178
+ .ask-resize{position:absolute;z-index:5;background:transparent;touch-action:none;--ask-grip:transparent;
3179
+ --ask-corner:calc(var(--r-card) - 1px);transition:box-shadow .15s;}
3180
+ .ask-resize:hover{--ask-grip:var(--ink-3);}
3181
+ .ask-resize.is-active{--ask-grip:var(--ink);}
3182
+ .ask-resize-n{top:0;left:var(--ask-corner);right:var(--ask-corner);height:6px;cursor:ns-resize;box-shadow:inset 0 2px 0 var(--ask-grip);}
3183
+ .ask-resize-e{top:var(--ask-corner);right:0;bottom:var(--ask-corner);width:6px;cursor:ew-resize;box-shadow:inset -2px 0 0 var(--ask-grip);}
3184
+ .ask-resize-w{top:var(--ask-corner);left:0;bottom:var(--ask-corner);width:6px;cursor:ew-resize;box-shadow:inset 2px 0 0 var(--ask-grip);}
3185
+ .ask-resize-ne{top:0;right:0;width:var(--ask-corner);height:var(--ask-corner);cursor:nesw-resize;
3186
+ border-top-right-radius:var(--ask-corner);box-shadow:inset -2px 2px 0 var(--ask-grip);}
3187
+ .ask-resize-nw{top:0;left:0;width:var(--ask-corner);height:var(--ask-corner);cursor:nwse-resize;
3188
+ border-top-left-radius:var(--ask-corner);box-shadow:inset 2px 2px 0 var(--ask-grip);}
3189
+ .ask-sheet.is-resizing{user-select:none;}
3190
+ /* The ne corner grip (one radius square, z-index:5) overlaps the top-right
3191
+ corner of the header's last icon button (30×30 at padding 13px/14px): the
3192
+ buttons sit above the grips so a click there still closes/opens instead of
3193
+ starting a resize, and a hurried double-click never resets the size. The
3194
+ grip's visible band hugs the border, well outside the button box. */
3195
+ .ask-header .ask-icon-btn{position:relative;z-index:6;}
3196
+
3148
3197
  /* ---------- Ask Worca dock clearance ---------- */
3149
3198
  /* The dock is a fixed overlay -- .ask-dock above: 26px dock padding-bottom plus the
3150
3199
  ~47px pill occludes a ~73px strip at the bottom of every scrollport, while the page
package/ui/server.mjs CHANGED
@@ -183,7 +183,24 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
183
183
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
184
184
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
185
185
  const require = createRequire(import.meta.url);
186
- const PKG_VERSION = require('../package.json').version;
186
+ const PKG = require('../package.json');
187
+ const PKG_VERSION = PKG.version;
188
+ // Settings ▸ About identity, fixed at module load. repository.url is normalised
189
+ // from npm's git forms (git+https, ssh://git@, git://, scp-style git@host:path)
190
+ // to a browsable https URL; releaseUrl points at the tag the release workflow
191
+ // publishes from (.github/workflows/release-npm-app.yml: worca-app-v<version>).
192
+ const repoWebUrl = (raw) => String(raw || '')
193
+ .replace(/^git\+/, '')
194
+ .replace(/^ssh:\/\/git@/, 'https://')
195
+ .replace(/^git:\/\//, 'https://')
196
+ .replace(/^git@([^:/]+):/, 'https://$1/')
197
+ .replace(/\.git$/, '');
198
+ const APP_REPO_URL = repoWebUrl(PKG.repository && PKG.repository.url);
199
+ const APP_INFO = Object.freeze({
200
+ version: PKG_VERSION || '',
201
+ repoUrl: APP_REPO_URL,
202
+ releaseUrl: APP_REPO_URL && PKG_VERSION ? `${APP_REPO_URL}/releases/tag/worca-app-v${PKG_VERSION}` : '',
203
+ });
187
204
  const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
188
205
  // Primaries plus the sub-language grammars their instances register
189
206
  // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
@@ -2777,6 +2794,10 @@ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
2777
2794
  // projectsRootDefault : what applies when projectsRoot is blank — the env tier
2778
2795
  // when exported, else defaultRoot(). The UI placeholder.
2779
2796
  // Additive; `default` keeps its `root` meaning.
2797
+ // app : { version, repoUrl, releaseUrl } — static identity for
2798
+ // the About card (APP_INFO, from package.json). GET-only:
2799
+ // it is not a setting, so POST keeps echoing
2800
+ // settingsState() + chat unchanged.
2780
2801
  // POST /api/settings -> set either key and return the resulting full state.
2781
2802
  // Only keys PRESENT in the body are written, so a projectsRoot-only POST can
2782
2803
  // never reset `root` (and vice versa). An explicitly empty value still resets
@@ -2845,7 +2866,7 @@ app.post('/api/shutdown', (req, res) => {
2845
2866
  });
2846
2867
 
2847
2868
  app.get('/api/settings', (_req, res) => {
2848
- res.json({ ...settingsState(), chat: chatPrefs() });
2869
+ res.json({ ...settingsState(), chat: chatPrefs(), app: APP_INFO });
2849
2870
  });
2850
2871
 
2851
2872
  app.get('/api/budget', (_req, res) => {