@worca/app 1.4.0 → 1.5.0-rc.2

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.
Files changed (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
package/ui/public/app.js CHANGED
@@ -11,6 +11,9 @@ const AUTO_WORKFLOW_ID = AUTO_WORKFLOW.id;
11
11
  // Memory scope control for it and the run body carries `memoryScope`. Built-in, never a saved row.
12
12
  const MEMORY_DEFRAG_WORKFLOW_ID = 'wf_memory_defrag';
13
13
 
14
+ // Before any request: turns an expired identity-proxy sign-in into a banner.
15
+ const sessionGuard = installSessionGuard();
16
+
14
17
  // ---------------------------------------------------------------------------
15
18
  // App state
16
19
  // ---------------------------------------------------------------------------
@@ -40,6 +43,7 @@ const state = {
40
43
  historyAll: [], // full /api/history dataset; client-side filter cache
41
44
  commentCounts: {}, // "<storeKey>/<pipelineId>" -> unresolved diff-comment count
42
45
  historyFilter: '', // active projectKey filter for History; '' === All Projects
46
+ historyPerson: '', // active "Started by" filter (lower-cased name); '' === everyone. Shared deployments only.
43
47
  ghAvailable: false,// gh CLI availability, from the last /api/history load
44
48
 
45
49
  // --- Workspaces ---
@@ -96,6 +100,7 @@ import { exportSlugPreview } from './export-slug.mjs';
96
100
  import { createCodeEditor } from './code-editor.mjs';
97
101
  import { previewAskFromDef, previewFileUrl } from './ask/form-preview.mjs';
98
102
  import { projectForm } from '../../src/shared/forms/project.mjs';
103
+ import { installSessionGuard } from './session-guard.mjs';
99
104
  import {
100
105
  renderPluginList, renderInstallConsent, renderUpdatePreview,
101
106
  renderConfigForm, collectConfigForm, renderConnectResult, renderDoctorReport, renderReferences409,
@@ -144,6 +149,8 @@ import {
144
149
  renderPolicyHeader, renderPolicyStats, renderPolicyPluginsPanel, renderPolicyCatalogPanel,
145
150
  } from './team-policy-view.mjs';
146
151
  import { aggregate, toCsv } from '../../src/shared/team-metrics/aggregate.mjs';
152
+ import { buildWorkItems, prLookupFor } from '../../src/shared/team-metrics/timeline.mjs';
153
+ import { renderTimeline, renderTimelinePopover, timelineWindow, shiftAnchor, TL_MODES, TL_ZOOMS } from './team-metrics-timeline.mjs';
147
154
  import {
148
155
  renderProjectTmCell, renderProjectTmChip, projectTmSummary, renderEnableDialogBody, renderMetricsHomePicker, renderWsMetricsRow, renderWsSummary, renderRouteResults, renderWsMetricsPending } from './team-metrics-surfaces.mjs';
149
156
  import { paintAboutInto } from './about-links.mjs';
@@ -370,6 +377,12 @@ const el = {
370
377
  projAddSave: $('#proj-add-save'),
371
378
  projAddCancel: $('#proj-add-cancel'),
372
379
  projAddMsg: $('#proj-add-msg'),
380
+ projAddTabs: $('#proj-add-tabs'),
381
+ projAddFolderPane: $('#proj-add-folder-pane'),
382
+ projAddClonePane: $('#proj-add-clone-pane'),
383
+ projCloneUrl: $('#proj-clone-url'),
384
+ projCloneBranch: $('#proj-clone-branch'),
385
+ projCloneName: $('#proj-clone-name'),
373
386
 
374
387
  // Agent creation wizard
375
388
  agwName: $('#agw-name'),
@@ -497,6 +510,7 @@ function connectWS() {
497
510
 
498
511
  ws.addEventListener('close', () => {
499
512
  state.wsReady = false;
513
+ sessionGuard.check(); // behind an identity proxy, a dropped socket may be an expired sign-in
500
514
  scheduleReconnect();
501
515
  });
502
516
 
@@ -753,6 +767,90 @@ function startBudgetTick() {
753
767
  budgetState.timer.unref?.(); // no-op in browsers/jsdom (number)
754
768
  }
755
769
 
770
+ // Who started a run (the server's identity.mjs): the name to show, or '' when there is
771
+ // nobody in particular — null, or 'local' on a local install / the CLI.
772
+ function attributedName(v) {
773
+ const s = typeof v === 'string' ? v.trim() : '';
774
+ return s && s !== 'local' ? s : '';
775
+ }
776
+
777
+ // Who is looking (GET /api/whoami, fetched once at boot). People are shown ONLY on a shared
778
+ // deployment — a real per-person sign-in (Cloudflare Access or a trusted header). A local
779
+ // install, or a one-person WORCA_IDENTITY_NAME deployment, stores who started a run but
780
+ // shows none of it: there is only ever one answer to "who".
781
+ const viewer = { shared: false, name: null };
782
+
783
+ /** The person to show for a stored name, or '' (not shared, nobody, or 'local'). */
784
+ function personShown(v) {
785
+ return viewer.shared ? attributedName(v) : '';
786
+ }
787
+ /** True when `name` is the signed-in viewer (case-insensitive). */
788
+ function isViewer(name) {
789
+ return !!(viewer.name && name && name.toLowerCase() === viewer.name.toLowerCase());
790
+ }
791
+ /** The short label for cards and banners: 'you' for the viewer, else the name, else ''. */
792
+ function personLabel(v) {
793
+ const n = personShown(v);
794
+ return !n ? '' : isViewer(n) ? 'you' : n;
795
+ }
796
+ /** Up to two initials: an email's local part (or a display name) split on . _ - and spaces. */
797
+ function personInitials(name) {
798
+ const base = String(name || '').trim().split('@')[0];
799
+ const parts = base.split(/[\s._-]+/).filter(Boolean);
800
+ const ini = parts.slice(0, 2).map((w) => w[0]).join('').toUpperCase();
801
+ return /^[A-Z0-9]{1,2}$/.test(ini) ? ini : (ini ? ini.replace(/[^A-Z0-9]/g, '').slice(0, 2) || '?' : '?');
802
+ }
803
+ /** A neutral initials circle; `title` is the full sentence. Text only, never markup. */
804
+ function personIni(name, title) {
805
+ const el = document.createElement('span');
806
+ el.className = 'person-ini';
807
+ el.textContent = personInitials(name);
808
+ el.setAttribute('aria-hidden', 'true');
809
+ if (title) el.title = title;
810
+ return el;
811
+ }
812
+ /** The detail-header person chip: initials circle + the full name (never "you"). */
813
+ function personChip(name, verb = 'Started by') {
814
+ const chip = document.createElement('span');
815
+ chip.className = 'person-chip';
816
+ chip.title = `${verb} ${name}`;
817
+ chip.append(personIni(name));
818
+ const t = document.createElement('span');
819
+ t.className = 'person-chip-name';
820
+ t.textContent = name;
821
+ chip.append(t);
822
+ return chip;
823
+ }
824
+
825
+ // "Signed in as" in the rail foot, and the viewer every people-label compares against.
826
+ async function loadWhoami() {
827
+ const box = document.getElementById('side-who');
828
+ try {
829
+ const res = await fetch('/api/whoami');
830
+ if (!res.ok) return;
831
+ const who = await res.json();
832
+ const name = attributedName(who && who.name);
833
+ viewer.shared = !!(who && who.shared === true && name);
834
+ viewer.name = viewer.shared ? name : null;
835
+ if (box) {
836
+ box.querySelector('.side-who-name').textContent = viewer.shared ? name : '';
837
+ box.title = viewer.shared ? `Signed in as ${name}` : '';
838
+ box.hidden = !viewer.shared;
839
+ }
840
+ if (viewer.shared) repaintPeople();
841
+ } catch { /* nobody is shown */ }
842
+ }
843
+
844
+ // Everything that shows a person, repainted once the viewer is known (boot races the
845
+ // hello snapshot and the History fetch). Each painter is idempotent.
846
+ function repaintPeople() {
847
+ try { for (const r of runs.values()) if (r.el) paintRunCard(r); } catch { /* not booted */ }
848
+ try { const tabs = $('#nav-running-children'); if (tabs) tabs.dataset.tabsSig = ''; renderPipelineTabs(); } catch { /* not booted */ }
849
+ try { if (runDetailState.screen && runs.get(runDetailState.runId)) repaintRunDetail(runs.get(runDetailState.runId)); } catch { /* none open */ }
850
+ try { if (Array.isArray(state.historyAll) && state.historyAll.length) paintHistory(); } catch { /* not loaded */ }
851
+ try { schedulesView.repaint(); paintScheduledGroup(); } catch { /* not booted */ }
852
+ }
853
+
756
854
  // The indicator is re-rendered on every paint, and .side-foot sits OUTSIDE the
757
855
  // <nav> that navLinks snapshots at boot — so route it from a container listener
758
856
  // rather than the [data-nav] delegation.
@@ -864,6 +962,10 @@ function handleServerMessage(msg) {
864
962
  scheduleOnboardingRefresh();
865
963
  return;
866
964
  }
965
+ if (msg.type === 'clone-changed') {
966
+ onCloneJob(msg.job);
967
+ return;
968
+ }
867
969
  if (msg.type === 'projects-changed') {
868
970
  scheduleOnboardingRefresh();
869
971
  refreshAllCounts();
@@ -959,6 +1061,8 @@ function handleServerMessage(msg) {
959
1061
  projectDir: msg.projectDir,
960
1062
  status: msg.status || 'starting',
961
1063
  startedAt: msg.startedAt,
1064
+ startedBy: msg.startedBy || undefined,
1065
+ lastAction: msg.lastAction || undefined,
962
1066
  kind: msg.kind || 'run',
963
1067
  workspaceId: msg.workspaceId || undefined,
964
1068
  projectNames: Array.isArray(msg.projectNames) && msg.projectNames.length ? msg.projectNames : undefined,
@@ -1067,6 +1171,8 @@ function onHello(msg) {
1067
1171
  pipelineId: r0.pipelineId || null,
1068
1172
  pauseReason: r0.pauseReason || null,
1069
1173
  pauseDetail: r0.pauseDetail || null,
1174
+ startedBy: r0.startedBy || undefined,
1175
+ lastAction: r0.lastAction || undefined,
1070
1176
  workspaceId: r0.workspaceId || undefined,
1071
1177
  projectNames: Array.isArray(r0.projectNames) && r0.projectNames.length ? r0.projectNames : undefined,
1072
1178
  });
@@ -1196,7 +1302,7 @@ function nowHMS() {
1196
1302
  function makeRun({
1197
1303
  runId, title, projectDir, status = 'running', startedAt, local = false,
1198
1304
  pendingQuestion = null, kind = 'run', pipelineId = null, pauseReason = null,
1199
- pauseDetail = null,
1305
+ pauseDetail = null, startedBy = null, lastAction = null,
1200
1306
  workspaceId = undefined, workspaceName = undefined, projectNames = null,
1201
1307
  }) {
1202
1308
  return {
@@ -1212,6 +1318,8 @@ function makeRun({
1212
1318
  pauseReason, // why it paused, or null — ANY orchestrator pause code rides here
1213
1319
  // (e.g. 'usage_limit'); only the cost pair renders a cost banner
1214
1320
  pauseDetail, // the human-readable cause behind an 'error' pause, or null
1321
+ startedBy, // who started it (identity.mjs), or null; 'local' is never shown
1322
+ lastAction, // who last stopped / paused / resumed it: { kind, by, at } or null
1215
1323
  workspaceId,
1216
1324
  workspaceName,
1217
1325
  // Stable ordering key: assigned once per runId, never bumped by activity
@@ -1670,6 +1778,11 @@ function cycleAwareLabel(stepper, subAgents, groupKeys, steps = []) {
1670
1778
  function onState(r, msg) {
1671
1779
  if (msg.status) r.status = msg.status;
1672
1780
  if (msg.startedAt) r.startedAt = msg.startedAt;
1781
+ // The harness mirrors startedBy onto its state (creation-immutable), so a live card
1782
+ // learns who started the run from the first state snapshot.
1783
+ if (typeof msg.startedBy === 'string' && msg.startedBy) r.startedBy = msg.startedBy;
1784
+ // Who stopped / paused / resumed it (run-harness _recordAction), on every state snapshot.
1785
+ if (msg.lastAction === null || (msg.lastAction && typeof msg.lastAction.by === 'string')) r.lastAction = msg.lastAction;
1673
1786
  // Mirror the on-disk pipeline short id the orchestrator stamps onto state.id
1674
1787
  // after createPipeline. The server captures the same field (ui/server.mjs
1675
1788
  // wireRun); without this the run model only ever gets a pipelineId from the
@@ -9395,15 +9508,118 @@ function setProjAddMsg(text, kind) {
9395
9508
  el.projAddMsg.className = 'hint' + (kind ? ' ' + kind : '');
9396
9509
  }
9397
9510
 
9398
- function openProjectAddModal(path) {
9511
+ function openProjectAddModal(path, { mode = 'folder' } = {}) {
9399
9512
  el.projAddPath.value = path || '';
9400
9513
  el.projAddName.value = path ? basenameOf(path) : '';
9514
+ if (el.projCloneUrl) {
9515
+ el.projCloneUrl.value = '';
9516
+ el.projCloneBranch.value = '';
9517
+ el.projCloneName.value = '';
9518
+ el.projCloneName.placeholder = 'the repository name';
9519
+ }
9520
+ el.projectAddModal.classList.remove('hidden');
9521
+ setProjAddMode(mode);
9522
+ // A clone still in flight from an earlier open keeps its progress line.
9523
+ if (cloneFollow) { setProjAddMode('clone'); setProjAddMsg(`Cloning ${cloneFollow.url} …`); lockCloneForm(true); return; }
9401
9524
  // Informational hint only when there is no path (manual-entry fallback);
9402
9525
  // neutral default .hint styling (no .hint.warn class exists).
9403
- setProjAddMsg(path ? '' : 'Native folder picker unavailable — enter the project folder path, or browse with Choose folder….');
9404
- el.projectAddModal.classList.remove('hidden');
9405
- el.projAddName.focus();
9406
- el.projAddName.select();
9526
+ if (mode === 'folder') {
9527
+ setProjAddMsg(path ? '' : 'Native folder picker unavailable — enter the project folder path, or browse with Choose folder….');
9528
+ el.projAddName.focus();
9529
+ el.projAddName.select();
9530
+ }
9531
+ }
9532
+
9533
+ // ---- Add project: the Folder / Clone from URL tabs ------------------------------
9534
+ let projAddMode = 'folder';
9535
+
9536
+ function setProjAddMode(mode) {
9537
+ projAddMode = mode === 'clone' && el.projAddClonePane ? 'clone' : 'folder';
9538
+ if (!el.projAddTabs) return;
9539
+ for (const t of el.projAddTabs.querySelectorAll('.md-tab')) t.setAttribute('aria-selected', String(t.dataset.mode === projAddMode));
9540
+ el.projAddFolderPane.hidden = projAddMode !== 'folder';
9541
+ el.projAddClonePane.hidden = projAddMode !== 'clone';
9542
+ el.projAddSave.textContent = projAddMode === 'clone' ? 'Clone and add' : 'Add project';
9543
+ setProjAddMsg(projAddMode === 'clone' ? 'Worca clones the repository into its projects folder with the deployment\'s GitHub credential.' : '');
9544
+ if (projAddMode === 'clone') el.projCloneUrl.focus();
9545
+ }
9546
+
9547
+ /** "https://github.com/acme/api(.git)" -> "api", or '' when the URL does not name one repository. */
9548
+ function repoNameFromUrl(url) {
9549
+ const m = /^https:\/\/[^/\s]+\/[^/\s]+\/([^/\s?#]+?)(?:\.git)?\/?$/i.exec(String(url || '').trim());
9550
+ return m ? m[1] : '';
9551
+ }
9552
+
9553
+ // The clone being followed: { id, url } while a job runs. WS 'clone-changed' is the fast path,
9554
+ // a GET every 2 s the fallback (a dropped socket must not strand the dialog).
9555
+ let cloneFollow = null;
9556
+ let clonePoll = null;
9557
+
9558
+ function lockCloneForm(locked) {
9559
+ for (const n of [el.projCloneUrl, el.projCloneBranch, el.projCloneName, el.projAddSave]) if (n) n.disabled = locked;
9560
+ if (el.projAddTabs) for (const t of el.projAddTabs.querySelectorAll('.md-tab')) t.disabled = locked;
9561
+ }
9562
+
9563
+ async function saveProjectClone() {
9564
+ const url = el.projCloneUrl.value.trim();
9565
+ if (!url) return setProjAddMsg('Repository URL is required.', 'err');
9566
+ const body = { url };
9567
+ const branch = el.projCloneBranch.value.trim();
9568
+ const name = el.projCloneName.value.trim();
9569
+ if (branch) body.branch = branch;
9570
+ if (name) body.name = name;
9571
+ lockCloneForm(true);
9572
+ setProjAddMsg(`Cloning ${url} …`);
9573
+ try {
9574
+ const res = await fetch('/api/projects/clone', {
9575
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
9576
+ });
9577
+ const data = await safeJson(res);
9578
+ if (!res.ok || !data.jobId) { lockCloneForm(false); setProjAddMsg(data.error || `HTTP ${res.status}`, 'err'); return; }
9579
+ cloneFollow = { id: data.jobId, url };
9580
+ if (data.job && data.job.state !== 'running') { onCloneJob(data.job); return; }
9581
+ clonePoll = setInterval(pollCloneJob, 2000);
9582
+ } catch (e) {
9583
+ lockCloneForm(false);
9584
+ setProjAddMsg(e.message, 'err');
9585
+ }
9586
+ }
9587
+
9588
+ async function pollCloneJob() {
9589
+ if (!cloneFollow) return;
9590
+ try {
9591
+ const res = await fetch(`/api/projects/clone/${encodeURIComponent(cloneFollow.id)}`);
9592
+ const data = await safeJson(res);
9593
+ if (res.ok && data.job) onCloneJob(data.job);
9594
+ else if (res.status === 404) onCloneJob({ id: cloneFollow.id, state: 'error', error: 'the clone job is gone (the server restarted?)' });
9595
+ } catch { /* the next tick retries */ }
9596
+ }
9597
+
9598
+ /** A job update from the WS or the poll: only the one this dialog started counts. */
9599
+ function onCloneJob(job) {
9600
+ if (!job || !cloneFollow || job.id !== cloneFollow.id || job.state === 'running') return;
9601
+ clearInterval(clonePoll);
9602
+ clonePoll = null;
9603
+ cloneFollow = null;
9604
+ lockCloneForm(false);
9605
+ const open = el.projectAddModal && !el.projectAddModal.classList.contains('hidden');
9606
+ if (job.state === 'error') {
9607
+ if (open) { setProjAddMode('clone'); setProjAddMsg(job.error || 'The clone failed.', 'err'); }
9608
+ else setProjectsMsg(`Clone failed: ${job.error || 'unknown error'}`, 'err');
9609
+ return;
9610
+ }
9611
+ const name = (job.project && job.project.name) || job.name || '';
9612
+ if (open) closeProjectAddModal();
9613
+ void (async () => {
9614
+ try {
9615
+ const res = await fetch('/api/projects');
9616
+ const data = await safeJson(res);
9617
+ if (res.ok && Array.isArray(data.projects)) state.projects = data.projects;
9618
+ } catch { /* the projects-changed frame refreshes it too */ }
9619
+ renderProjectsList();
9620
+ renderProjectOptions(localStorage.getItem(LAST_PROJECT_KEY) || '');
9621
+ setProjectsMsg(name ? `Cloned and added “${name}”.` : 'Cloned and added the project.');
9622
+ })();
9407
9623
  }
9408
9624
 
9409
9625
  function closeProjectAddModal() {
@@ -9416,10 +9632,13 @@ async function addProjectFlow() {
9416
9632
  if (data && data.status === 'picked' && data.path) { openProjectAddModal(data.path); return; }
9417
9633
  if (data && data.status === 'canceled') return; // respect the cancel
9418
9634
  if (data && data.status === 'busy') { setProjectsMsg('A folder dialog is already open — finish or cancel it first.', 'err'); return; }
9419
- openProjectAddModal(''); // unsupported / error -> manual entry
9635
+ // No folder picker at all (a container or hosted worca): the repository is the way in.
9636
+ if (data && data.status === 'unsupported') { openProjectAddModal('', { mode: 'clone' }); return; }
9637
+ openProjectAddModal(''); // error -> manual entry
9420
9638
  }
9421
9639
 
9422
9640
  async function saveProjectAdd() {
9641
+ if (projAddMode === 'clone') return saveProjectClone();
9423
9642
  const name = el.projAddName.value.trim();
9424
9643
  const path = el.projAddPath.value.trim();
9425
9644
  if (!name) return setProjAddMsg('Name is required.', 'err');
@@ -9526,6 +9745,16 @@ if (el.projAddSave) {
9526
9745
  el.projAddBrowse.disabled = false;
9527
9746
  }
9528
9747
  });
9748
+ if (el.projAddTabs) {
9749
+ el.projAddTabs.addEventListener('click', (e) => {
9750
+ const tab = e.target.closest && e.target.closest('.md-tab');
9751
+ if (tab && !tab.disabled) setProjAddMode(tab.dataset.mode);
9752
+ });
9753
+ el.projCloneUrl.addEventListener('input', () => {
9754
+ el.projCloneName.placeholder = repoNameFromUrl(el.projCloneUrl.value) || 'the repository name';
9755
+ });
9756
+ el.projCloneUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); void saveProjectAdd(); } });
9757
+ }
9529
9758
  el.projectAddModal.addEventListener('click', (e) => { if (e.target === el.projectAddModal) closeProjectAddModal(); });
9530
9759
  document.addEventListener('keydown', (e) => {
9531
9760
  if (e.key === 'Escape' && el.projectAddModal && !el.projectAddModal.classList.contains('hidden')) closeProjectAddModal();
@@ -14409,6 +14638,15 @@ const tmState = {
14409
14638
  filter: {}, sort: {}, data: null, loading: false, loadSeq: 0,
14410
14639
  runLimit: TM_RUN_PAGE, // raised by "Show all N runs" (§4.9 "every record in range")
14411
14640
  cache: new Map(), // scopeId → last payload this session: switching back paints at once
14641
+ tab: 'overview', // 'overview' | 'timeline' — the hash param (#team-metrics/timeline)
14642
+ // Timeline: calendar position, grouping (kept per viewer) and the tile filter.
14643
+ tl: {
14644
+ zoom: 'month', anchor: Date.now(), filter: null,
14645
+ mode: TL_MODES.includes(localStorage.getItem('worca.teamMetrics.tlMode')) ? localStorage.getItem('worca.teamMetrics.tlMode') : 'items',
14646
+ outside: localStorage.getItem('worca.teamMetrics.tlOutside') !== '0', // PRs with no Worca run behind them
14647
+ },
14648
+ // scopeId → { map: runId → PRs|null, asked:Set, status, inflight, loading, events: PR events|null, eventsInflight }
14649
+ prs: new Map(),
14412
14650
  };
14413
14651
 
14414
14652
  // The chip while a load is out: hold Refresh and spin, keep the words. Cleared by the next
@@ -14452,6 +14690,7 @@ async function loadTeamMetricsView({ refresh = false } = {}) {
14452
14690
  body.classList.remove('is-loading');
14453
14691
  body.removeAttribute('aria-busy');
14454
14692
  body.replaceChildren(renderTmEmptyState({ doc: document }));
14693
+ applyTmTab();
14455
14694
  return;
14456
14695
  }
14457
14696
  // Instant paint (docs/team-metrics.md "Loading"): this scope's last payload of the session when
@@ -14466,6 +14705,7 @@ async function loadTeamMetricsView({ refresh = false } = {}) {
14466
14705
  } else if (!tmState.data) {
14467
14706
  body.replaceChildren(renderTmSkeleton({ doc: document, scopeKind: tmState.scopeId.startsWith('workspace:') ? 'workspace' : 'project' }));
14468
14707
  }
14708
+ applyTmTab();
14469
14709
  // aggregate=0: this page always re-aggregates the records itself (range/group/filter are local).
14470
14710
  // defer=1: the worktree answers now; a due fetch runs after the response and its `changed`
14471
14711
  // event reloads the page — the chip says "Checking origin…" meanwhile.
@@ -14487,14 +14727,194 @@ async function loadTeamMetricsView({ refresh = false } = {}) {
14487
14727
  body.classList.remove('is-loading');
14488
14728
  body.removeAttribute('aria-busy');
14489
14729
  body.replaceChildren(Object.assign(document.createElement('small'), { className: 'hint err', textContent: `Could not load team metrics: ${err.message}` }));
14730
+ applyTmTab();
14490
14731
  return;
14491
14732
  }
14492
14733
  tmState.data = data;
14493
14734
  tmState.cache.set(tmState.scopeId, data);
14494
14735
  chip.hidden = false;
14495
14736
  chip.replaceChildren(renderSyncChip(data, { doc: document, now: Date.now() }));
14737
+ if (refresh) { const p = tmPrsFor(tmState.scopeId); p.asked.clear(); p.events = null; } // Refresh re-asks for the PRs too
14496
14738
  renderTeamMetrics();
14497
14739
  body.removeAttribute('aria-busy');
14740
+ applyTmTab();
14741
+ }
14742
+
14743
+ // ---- Team metrics → Timeline (docs/team-metrics.md "Timeline") -----------------------------
14744
+ const TL_DAY = 86_400_000;
14745
+ const TL_PR_LOOKBACK = 60 * TL_DAY; // runs this far before the window can still have bars in it
14746
+ const TL_PR_CHUNK = 500;
14747
+
14748
+ function tmPrsFor(scopeId) {
14749
+ if (!tmState.prs.has(scopeId)) tmState.prs.set(scopeId, { map: new Map(), asked: new Set(), status: null, inflight: false, loading: false, events: null, eventsInflight: false });
14750
+ return tmState.prs.get(scopeId);
14751
+ }
14752
+
14753
+ /**
14754
+ * Every PR the merge-tracking Action recorded for the scope, once per scope per session (Refresh
14755
+ * asks again). The ones no run points at are drawn as work outside Worca. Without the Action
14756
+ * the list is empty and nothing changes.
14757
+ */
14758
+ async function ensureTmPrEvents() {
14759
+ const scopeId = tmState.scopeId;
14760
+ const pr = tmPrsFor(scopeId);
14761
+ if (pr.events || pr.eventsInflight || !tmState.data || tmState.tab !== 'timeline') return;
14762
+ pr.eventsInflight = true;
14763
+ try {
14764
+ const res = await fetch(`/api/team-metrics/pr-events?${new URLSearchParams({ scope: scopeId })}`);
14765
+ const d = await safeJson(res);
14766
+ pr.events = res.ok && d && Array.isArray(d.prs) ? d.prs : [];
14767
+ } catch { pr.events = []; }
14768
+ finally { pr.eventsInflight = false; }
14769
+ if (pr.events.length && tmState.scopeId === scopeId && tmState.tab === 'timeline' && currentView() === 'team-metrics') { tmState.tlKeepScroll = true; renderTmTimeline(); }
14770
+ }
14771
+
14772
+ /** Tabs: Overview keeps the range/filter controls; the Timeline has its own calendar. */
14773
+ function applyTmTab() {
14774
+ const tl = tmState.tab === 'timeline';
14775
+ $$('#tm-tabs [data-tm-tab]').forEach((b) => {
14776
+ const on = b.dataset.tmTab === tmState.tab;
14777
+ b.classList.toggle('on', on);
14778
+ b.setAttribute('aria-selected', String(on));
14779
+ });
14780
+ const range = document.getElementById('tm-range');
14781
+ if (range) range.hidden = tl;
14782
+ const custom = document.getElementById('tm-custom');
14783
+ if (custom) custom.hidden = tl || tmState.range !== 'custom';
14784
+ const chips = document.getElementById('tm-filters');
14785
+ if (chips && tl) chips.hidden = true;
14786
+ // No data yet (skeleton, empty state, an error): #tm-body says so on either tab.
14787
+ const showTl = tl && !!tmState.data;
14788
+ const body = document.getElementById('tm-body');
14789
+ const host = document.getElementById('tm-timeline');
14790
+ if (body) body.hidden = showTl;
14791
+ if (host) host.hidden = !showTl;
14792
+ if (showTl) { renderTmTimeline(); tlScrollToNow(); }
14793
+ }
14794
+
14795
+ /** px the calendar can fill: the card's width minus the sticky label column (style.css --tl-lab). */
14796
+ function tlFit(host) {
14797
+ const lab = window.matchMedia && window.matchMedia('(max-width:720px)').matches ? 176 : 300;
14798
+ return Math.max(0, (host.clientWidth || 0) - lab - 2);
14799
+ }
14800
+
14801
+ function renderTmTimeline() {
14802
+ const host = document.getElementById('tm-timeline');
14803
+ const data = tmState.data;
14804
+ if (!host || !data) return;
14805
+ const pr = tmPrsFor(tmState.scopeId);
14806
+ const now = Date.now();
14807
+ tmState.tlItems = buildWorkItems(data.records || [], { prs: Object.fromEntries(pr.map), outside: pr.events || [], now });
14808
+ const scroll = host.querySelector('.tl-scroll');
14809
+ const keepScroll = scroll ? scroll.scrollLeft : 0;
14810
+ tmState.tlFit = tlFit(host);
14811
+ host.replaceChildren(renderTimeline({ ...tmState.tl, items: tmState.tlItems, now, prStatus: pr.status, prLoading: pr.loading, fit: tmState.tlFit }, { doc: document }));
14812
+ const next = host.querySelector('.tl-scroll');
14813
+ if (next && tmState.tlKeepScroll) next.scrollLeft = keepScroll;
14814
+ tmState.tlKeepScroll = false;
14815
+ void ensureTmPrs();
14816
+ void ensureTmPrEvents();
14817
+ }
14818
+
14819
+ function mergePrStatus(a, b) {
14820
+ if (!a) return b;
14821
+ if (!b) return a;
14822
+ return {
14823
+ ...b,
14824
+ gh: b.gh !== 'unused' ? b.gh : a.gh,
14825
+ ghDetail: b.ghDetail ?? a.ghDetail,
14826
+ ghError: b.ghError ?? a.ghError,
14827
+ actionRepos: [...new Set([...(a.actionRepos || []), ...(b.actionRepos || [])])],
14828
+ unsupportedRepos: [...new Set([...(a.unsupportedRepos || []), ...(b.unsupportedRepos || [])])],
14829
+ };
14830
+ }
14831
+
14832
+ /**
14833
+ * Asks the server for the pull requests behind the runs the window can show (and a lookback
14834
+ * for work that started earlier), once per run per session. The page paints without them first;
14835
+ * "Checking pull requests…" shows meanwhile, and the answer repaints the timeline.
14836
+ */
14837
+ async function ensureTmPrs() {
14838
+ const scopeId = tmState.scopeId;
14839
+ const pr = tmPrsFor(scopeId);
14840
+ if (pr.inflight || !tmState.data || tmState.tab !== 'timeline') return;
14841
+ const win = timelineWindow(tmState.tl.zoom, tmState.tl.anchor);
14842
+ const lo = win.s - TL_PR_LOOKBACK;
14843
+ const need = (tmState.data.records || []).filter((r) => {
14844
+ const t = Date.parse(r.startedAt);
14845
+ return t >= lo && t < win.e && !pr.asked.has(r.id);
14846
+ });
14847
+ if (!need.length) return;
14848
+ pr.inflight = true; pr.loading = true;
14849
+ // Asked once per session, answer or not: a failing lookup must not loop (render → ask → fail →
14850
+ // render). Refresh clears the set and tries again.
14851
+ for (const r of need) pr.asked.add(r.id);
14852
+ renderTmTimeline();
14853
+ try {
14854
+ for (let i = 0; i < need.length; i += TL_PR_CHUNK) {
14855
+ const chunk = need.slice(i, i + TL_PR_CHUNK);
14856
+ const res = await fetch('/api/team-metrics/prs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: scopeId, runs: chunk.map(prLookupFor) }) });
14857
+ const d = await safeJson(res);
14858
+ if (!res.ok) { pr.status = mergePrStatus(pr.status, { gh: 'unused', ghError: (d && d.error) || `HTTP ${res.status}` }); break; }
14859
+ for (const [id, v] of Object.entries(d.prs || {})) pr.map.set(id, v);
14860
+ pr.status = mergePrStatus(pr.status, d.status);
14861
+ }
14862
+ } catch (err) {
14863
+ pr.status = mergePrStatus(pr.status, { gh: 'unused', ghError: err.message });
14864
+ } finally {
14865
+ pr.inflight = false; pr.loading = false;
14866
+ }
14867
+ if (tmState.scopeId === scopeId && tmState.tab === 'timeline' && currentView() === 'team-metrics') { tmState.tlKeepScroll = true; renderTmTimeline(); }
14868
+ }
14869
+
14870
+ function closeTlPopover() { document.querySelectorAll('.tl-pop').forEach((p) => p.remove()); }
14871
+
14872
+ function openTlPopover(hit) {
14873
+ closeTlPopover();
14874
+ const it = (tmState.tlItems || []).find((x) => x.key === hit.dataset.tlItem);
14875
+ if (!it) return;
14876
+ const pop = renderTimelinePopover(it, { doc: document, now: Date.now() });
14877
+ document.getElementById('tm-timeline').append(pop);
14878
+ const r = hit.getBoundingClientRect();
14879
+ const pw = pop.offsetWidth || 340; const ph = pop.offsetHeight || 300;
14880
+ const x = Math.min(Math.max(16, r.left + Math.min(r.width, 60)), window.innerWidth - pw - 16);
14881
+ let y = r.bottom + 8;
14882
+ if (y + ph > window.innerHeight - 16) y = Math.max(16, r.top - ph - 8);
14883
+ pop.style.left = `${x}px`; pop.style.top = `${y}px`;
14884
+ pop.querySelector('.tl-pop-x')?.focus();
14885
+ }
14886
+
14887
+ function tlScrollToNow() {
14888
+ const sc = document.querySelector('#tm-timeline .tl-scroll');
14889
+ const win = timelineWindow(tmState.tl.zoom, tmState.tl.anchor, { fit: tmState.tlFit });
14890
+ const now = Date.now();
14891
+ if (!sc || now < win.s || now >= win.e) return;
14892
+ sc.scrollLeft = Math.max(0, ((now - win.s) / (win.e - win.s)) * win.W - sc.clientWidth / 2);
14893
+ }
14894
+
14895
+ /** Delegated clicks inside #tm-timeline. Returns true when the click was the timeline's. */
14896
+ function onTimelineClick(e) {
14897
+ const t = e.target;
14898
+ if (t.closest('[data-tl-close]')) { closeTlPopover(); return true; }
14899
+ if (t.closest('.tl-pop')) return true;
14900
+ const hit = t.closest('[data-tl-item]');
14901
+ if (hit) { openTlPopover(hit); return true; }
14902
+ const tl = tmState.tl;
14903
+ const ctl = t.closest('[data-tl-filter],[data-tl-mode],[data-tl-shift],[data-tl-today],[data-tl-zoom],[data-tl-day],[data-tl-week]');
14904
+ closeTlPopover();
14905
+ if (!ctl) return false;
14906
+ const d = ctl.dataset;
14907
+ if (d.tlFilter) tl.filter = tl.filter === d.tlFilter ? null : d.tlFilter;
14908
+ else if (d.tlMode) { tl.mode = d.tlMode; try { localStorage.setItem('worca.teamMetrics.tlMode', tl.mode); } catch { /* private mode */ } }
14909
+ else if (d.tlShift) tl.anchor = shiftAnchor(tl.zoom, tl.anchor, Number(d.tlShift));
14910
+ else if (d.tlToday) tl.anchor = Date.now();
14911
+ else if (d.tlZoom && TL_ZOOMS.includes(d.tlZoom)) tl.zoom = d.tlZoom;
14912
+ else if (d.tlDay) { tl.zoom = 'day'; tl.anchor = Number(d.tlDay) + 12 * 3_600_000; }
14913
+ else if (d.tlWeek) { tl.zoom = 'week'; tl.anchor = Number(d.tlWeek) + 12 * 3_600_000; }
14914
+ tmState.tlKeepScroll = !!(d.tlFilter || d.tlMode);
14915
+ renderTmTimeline();
14916
+ if (d.tlToday) tlScrollToNow();
14917
+ return true;
14498
14918
  }
14499
14919
 
14500
14920
  function renderTeamMetrics() {
@@ -14538,6 +14958,14 @@ function renderTeamMetrics() {
14538
14958
  const tmSection = document.querySelector('section[data-view="team-metrics"]');
14539
14959
  if (tmSection) {
14540
14960
  tmSection.addEventListener('change', (e) => {
14961
+ if (e.target.id === 'tl-outside') {
14962
+ tmState.tl.outside = e.target.checked;
14963
+ try { localStorage.setItem('worca.teamMetrics.tlOutside', tmState.tl.outside ? '1' : '0'); } catch { /* private mode */ }
14964
+ closeTlPopover();
14965
+ tmState.tlKeepScroll = true;
14966
+ renderTmTimeline();
14967
+ return;
14968
+ }
14541
14969
  if (e.target.id === 'tm-scope') {
14542
14970
  tmState.scopeId = e.target.value;
14543
14971
  tmState.filter = {};
@@ -14552,6 +14980,14 @@ if (tmSection) {
14552
14980
  else if (e.target.id === 'tm-from' || e.target.id === 'tm-to') { tmState[e.target.id === 'tm-from' ? 'from' : 'to'] = e.target.value; if (tmState.from && tmState.to) renderTeamMetrics(); }
14553
14981
  });
14554
14982
  tmSection.addEventListener('click', async (e) => {
14983
+ const tabBtn = e.target.closest('[data-tm-tab]');
14984
+ if (tabBtn) {
14985
+ closeTlPopover();
14986
+ location.hash = tabBtn.dataset.tmTab === 'timeline' ? 'team-metrics/timeline' : 'team-metrics';
14987
+ return;
14988
+ }
14989
+ if (e.target.closest('#tm-timeline')) { onTimelineClick(e); return; }
14990
+ closeTlPopover();
14555
14991
  const rangeBtn = e.target.closest('#tm-range button');
14556
14992
  if (rangeBtn) {
14557
14993
  tmState.range = rangeBtn.dataset.range;
@@ -14596,12 +15032,30 @@ if (tmSection) {
14596
15032
  }
14597
15033
  });
14598
15034
  tmSection.addEventListener('keydown', (e) => {
15035
+ if (e.key === 'Escape' && document.querySelector('.tl-pop')) { closeTlPopover(); return; }
14599
15036
  if (e.key !== 'Enter' && e.key !== ' ') return;
14600
15037
  const t = e.target.closest('tr[data-filter-dim], table.tm-tbl th[data-sort]');
14601
15038
  if (t) { e.preventDefault(); t.click(); }
14602
15039
  });
14603
15040
  // Chart tooltip: reuse the Stats pattern (pointerover/focusin on .ch-hit → #tm-tip).
14604
15041
  bindChartTips(tmSection, document.getElementById('tm-tip'));
15042
+ // The Timeline's calendar fills the card: repaint it at the new width (debounced). Watching the
15043
+ // host, not the window, also catches the page's scrollbar appearing when the rows grow.
15044
+ let tlResizeTimer = null;
15045
+ const tlOnResize = () => {
15046
+ clearTimeout(tlResizeTimer);
15047
+ tlResizeTimer = setTimeout(() => {
15048
+ const host = document.getElementById('tm-timeline');
15049
+ if (!host || host.hidden || currentView() !== 'team-metrics' || tmState.tab !== 'timeline' || !tmState.data) return;
15050
+ if (tlFit(host) === tmState.tlFit) return;
15051
+ closeTlPopover();
15052
+ tmState.tlKeepScroll = true;
15053
+ renderTmTimeline();
15054
+ }, 150);
15055
+ };
15056
+ const tlHost = document.getElementById('tm-timeline');
15057
+ if (tlHost && typeof window.ResizeObserver === 'function') new window.ResizeObserver(tlOnResize).observe(tlHost);
15058
+ else window.addEventListener('resize', tlOnResize);
14605
15059
  }
14606
15060
 
14607
15061
  // ---------------------------------------------------------------------------
@@ -14889,12 +15343,57 @@ function renderHistoryPills() {
14889
15343
  host.appendChild(mkPill('', 'All Projects', state.historyAll.length));
14890
15344
  for (const pr of historyProjects()) host.appendChild(mkPill(pr.key, pr.name, pr.count, pr.workspace));
14891
15345
 
15346
+ // "Started by" pills (shared deployments only): the distinct starters in the loaded
15347
+ // history, the viewer first as "you". A second, independent filter.
15348
+ const people = historyPeople();
15349
+ if (people.length) {
15350
+ const lab = document.createElement('span');
15351
+ lab.className = 'hist-pill-label';
15352
+ lab.textContent = 'Started by';
15353
+ host.appendChild(lab);
15354
+ for (const pp of people) {
15355
+ const b = document.createElement('button');
15356
+ b.type = 'button';
15357
+ const active = state.historyPerson === pp.key;
15358
+ b.className = 'hist-pill person' + (active ? ' active' : '');
15359
+ b.dataset.person = pp.key;
15360
+ b.setAttribute('aria-pressed', active ? 'true' : 'false');
15361
+ b.title = active ? 'Show runs by everyone' : `Only runs started by ${pp.name}`;
15362
+ b.appendChild(personIni(pp.name));
15363
+ const txt = document.createElement('span');
15364
+ txt.textContent = pp.label;
15365
+ b.appendChild(txt);
15366
+ b.appendChild(document.createTextNode(' '));
15367
+ const c = document.createElement('span');
15368
+ c.className = 'pill-count';
15369
+ c.textContent = String(pp.count);
15370
+ b.appendChild(c);
15371
+ b.addEventListener('click', () => { state.historyPerson = active ? '' : pp.key; paintHistory(); });
15372
+ host.appendChild(b);
15373
+ }
15374
+ }
15375
+
14892
15376
  // Keep the sticky project header offset in sync with the toolbar's height
14893
15377
  // (also re-measures on resize, when pills wrap to more/fewer rows).
14894
15378
  ensureHistToolbarObserver();
14895
15379
  syncHistToolbarHeight();
14896
15380
  }
14897
15381
 
15382
+ /** The distinct people who started loaded runs: [{ key, name, label, count }], the viewer first. */
15383
+ function historyPeople() {
15384
+ if (!viewer.shared) return [];
15385
+ const byKey = new Map();
15386
+ for (const p of state.historyAll || []) {
15387
+ const n = personShown(p && p.startedBy);
15388
+ if (!n) continue;
15389
+ const key = n.toLowerCase();
15390
+ const e = byKey.get(key) || { key, name: n, label: isViewer(n) ? 'you' : n, count: 0 };
15391
+ e.count += 1;
15392
+ byKey.set(key, e);
15393
+ }
15394
+ return [...byKey.values()].sort((a, b) => (isViewer(b.name) - isViewer(a.name)) || b.count - a.count || a.name.localeCompare(b.name));
15395
+ }
15396
+
14898
15397
  // Switch the active project filter, persist it (so it survives reloads), repaint.
14899
15398
  // Selecting All Projects clears the memory (the default needs no stored value).
14900
15399
  function setHistoryFilter(key) {
@@ -14913,6 +15412,7 @@ function paintHistory() {
14913
15412
  state.historyFilter = '';
14914
15413
  localStorage.removeItem(HISTORY_FILTER_KEY);
14915
15414
  }
15415
+ if (state.historyPerson && !historyPeople().some((pp) => pp.key === state.historyPerson)) state.historyPerson = '';
14916
15416
  renderHistoryPills();
14917
15417
  renderHistory();
14918
15418
  // An open detail screen re-reads its (possibly late-arriving, possibly mutated)
@@ -14941,10 +15441,13 @@ function renderHistory() {
14941
15441
  const visible = hiddenPids.size ? all.filter((p) => !hiddenPids.has(p.id)) : all;
14942
15442
 
14943
15443
  const filter = state.historyFilter;
14944
- const records = filter ? visible.filter((p) => p && p.projectKey === filter) : visible;
15444
+ const person = viewer.shared ? state.historyPerson : '';
15445
+ const byProject = filter ? visible.filter((p) => p && p.projectKey === filter) : visible;
15446
+ const records = person ? byProject.filter((p) => personShown(p && p.startedBy).toLowerCase() === person) : byProject;
14945
15447
 
14946
15448
  if (!records.length) {
14947
- host.appendChild(histEmpty(filter ? 'No saved pipelines for this project yet.' : 'No saved pipelines yet.'));
15449
+ host.appendChild(histEmpty(person ? 'No saved pipelines started by this person here yet.'
15450
+ : filter ? 'No saved pipelines for this project yet.' : 'No saved pipelines yet.'));
14948
15451
  return;
14949
15452
  }
14950
15453
 
@@ -15339,6 +15842,15 @@ function buildHistCard(projectDir, p, ghAvailable = false) {
15339
15842
  seg('clock', clock);
15340
15843
  seg('time', typeof p.totalActiveMs === 'number' ? fmtDuration(p.totalActiveMs) : '');
15341
15844
  seg('total', typeof p.totalCostUsd === 'number' ? fmtUsd(p.totalCostUsd) : '');
15845
+ // Who started it (shared deployments only): an initials circle + "by you" / "by <name>".
15846
+ const by = personLabel(p.startedBy);
15847
+ seg('by', by ? `by ${by}` : '');
15848
+ if (by) {
15849
+ const full = personShown(p.startedBy);
15850
+ const byEl = node.querySelector('.hist-by');
15851
+ byEl.title = `Started by ${full}`;
15852
+ byEl.before(personIni(full));
15853
+ }
15342
15854
  if (typeof p.totalCostUsd === 'number') node.querySelector('.hist-total').title = estTitle(p.totalCostUsd);
15343
15855
 
15344
15856
  renderHistDiffPill(node.querySelector('.hist-diff-pill'), p);
@@ -16401,6 +16913,12 @@ function paintHdHeaderMeta(screen, record, data) {
16401
16913
  a.title = `Scheduled for ${fmtDate(st.scheduledFor)}`;
16402
16914
  meta.appendChild(a);
16403
16915
  }
16916
+ // Who started it (shared deployments only): the person chip at the end of the status row,
16917
+ // the full name (never "you"); for a scheduled run, who scheduled it.
16918
+ const row2 = screen.querySelector('.hd-row2');
16919
+ row2?.querySelector('.person-chip')?.remove();
16920
+ const by = personShown(st.startedBy) || personShown(record && record.startedBy);
16921
+ if (by && row2) row2.appendChild(personChip(by, st.scheduledFor ? 'Scheduled by' : 'Started by'));
16404
16922
  // spec §8: the End card's result chip, repeated in the header meta (History D5
16405
16923
  // untouched — no model/effort). A path links through the keyed artifact route.
16406
16924
  if (st.endReached === true && st.result) {
@@ -17372,7 +17890,10 @@ function hdCommentCard(doc, comment, ctx, { detached = false, reply = false, las
17372
17890
  }
17373
17891
  const who = doc.createElement('span');
17374
17892
  who.className = 'hd-cmt-author';
17375
- who.textContent = comment.author === 'ask' ? 'Worca' : 'You';
17893
+ // A person's comment names them once attribution knows who (identity.mjs); else the old "You".
17894
+ // Shared deployments name the author ("You" for the viewer); elsewhere every human comment is "You".
17895
+ const author = personLabel(comment.authorName);
17896
+ who.textContent = comment.author === 'ask' ? 'Worca' : (!author || author === 'you' ? 'You' : author);
17376
17897
  const when = doc.createElement('time');
17377
17898
  when.className = 'hd-cmt-time';
17378
17899
  when.dateTime = comment.createdAt || '';
@@ -18879,12 +19400,27 @@ function buildHdClarify(sec, record, data) {
18879
19400
  card.append(qRow, aRow);
18880
19401
  wrap.appendChild(card);
18881
19402
  };
19403
+ // Who answered (step 3): shown under step 2's rule (shared sign-ins only, "you" for the viewer).
19404
+ const answeredByLine = (by) => {
19405
+ const who = personLabel(by);
19406
+ if (!who) return null;
19407
+ const el = document.createElement('div');
19408
+ el.className = 'hint hd-cl-by';
19409
+ el.textContent = `answered by ${who}`;
19410
+ el.title = `Answered by ${personShown(by)}`;
19411
+ return el;
19412
+ };
18882
19413
  for (const q of questions) addCard(q, byId.get(q.id));
19414
+ if (questions.length && data.clarify) { const by = answeredByLine(data.clarify.answeredBy); if (by) wrap.appendChild(by); }
18883
19415
  if (data.clarify && data.clarify.ask) wrap.appendChild(hdRenderAskForm(record, data.clarify.ask));
18884
19416
  for (const r of Array.isArray(data.stepQuestions) ? data.stepQuestions : []) {
18885
19417
  const roundLabel = `${r && (r.agentKey || r.nodeId) ? (r.agentKey || r.nodeId) : 'agent'} — round ${r && r.round}`
18886
19418
  + (String((r && r.stepKey) || '').split('#')[1] ? ` · cycle ${String(r.stepKey).split('#')[1]}` : '');
18887
- if (r && r.ask) wrap.appendChild(hdRenderAskForm(record, r.ask, roundLabel));
19419
+ if (r && r.ask) {
19420
+ wrap.appendChild(hdRenderAskForm(record, r.ask, roundLabel));
19421
+ const by = answeredByLine(r.answeredBy);
19422
+ if (by) wrap.appendChild(by);
19423
+ }
18888
19424
  if (!((r && r.questions) || []).length) continue;
18889
19425
  const caption = document.createElement('div');
18890
19426
  caption.className = 'hint hd-cl-caption';
@@ -18892,6 +19428,8 @@ function buildHdClarify(sec, record, data) {
18892
19428
  wrap.appendChild(caption);
18893
19429
  const rById = new Map((r.answers || []).map((a) => [a.id, a]));
18894
19430
  for (const q of r.questions) addCard(q, rById.get(q.id));
19431
+ const by = answeredByLine(r.answeredBy);
19432
+ if (by) wrap.appendChild(by);
18895
19433
  }
18896
19434
  }
18897
19435
 
@@ -19226,7 +19764,9 @@ function rdStateCopy(r, stepName) {
19226
19764
  return `Paused — ${r.pauseReason}. Resume once it clears.`;
19227
19765
  }
19228
19766
  if (r.status === 'paused' || r.status === 'pausing' || r.status === 'interrupted') {
19229
- return 'Paused by you. Agents in flight finished their checkpoint; nothing new is dispatched.';
19767
+ // Who paused it (shared deployments only): "by you" when it was the viewer, else the name.
19768
+ const by = r.lastAction && r.lastAction.kind === 'pause' ? personLabel(r.lastAction.by) : '';
19769
+ return `Paused${by ? ` by ${by}` : ''}. Agents in flight finished their checkpoint; nothing new is dispatched.`;
19230
19770
  }
19231
19771
  if (RD_TERMINAL.includes(r.status)) {
19232
19772
  // finishedAtMs is stamped by finishRun (Task 9). Absent on a run this tab
@@ -19235,7 +19775,8 @@ function rdStateCopy(r, stepName) {
19235
19775
  const at = r.finishedAtMs
19236
19776
  ? ` Finished at ${startedLabel(new Date(r.finishedAtMs).toISOString())}.`
19237
19777
  : '';
19238
- return `${runStatusMeta(r).word}.${at}`;
19778
+ const by = r.status === 'stopped' && r.lastAction && r.lastAction.kind === 'stop' ? personLabel(r.lastAction.by) : '';
19779
+ return `${runStatusMeta(r).word}${by ? ` by ${by}` : ''}.${at}`;
19239
19780
  }
19240
19781
  // The ACTIVE agent names the line (the v1 phase/cycle scalars are gone).
19241
19782
  return `${activeCopy(r).text}.`;
@@ -20437,6 +20978,17 @@ function renderRunMeta(r, root = r.el) {
20437
20978
  if (!root) return;
20438
20979
  const metaEl = root.querySelector('.rm-text');
20439
20980
  if (metaEl) metaEl.textContent = `started ${startedLabel(r.startedAt)}`;
20981
+ const byEl = root.querySelector('.rc-by');
20982
+ if (byEl) {
20983
+ // Shared deployments only: an initials circle + "by you" / "by <name>".
20984
+ const by = personLabel(r.startedBy);
20985
+ const full = personShown(r.startedBy);
20986
+ byEl.hidden = !by;
20987
+ byEl.querySelector('.rc-by-text').textContent = by ? `by ${by}` : '';
20988
+ const ini = byEl.querySelector('.person-ini');
20989
+ if (ini) ini.textContent = full ? personInitials(full) : '';
20990
+ byEl.title = full ? `Started by ${full}` : '';
20991
+ }
20440
20992
 
20441
20993
  // D15: progress is a NUMBER, never a bar. Hidden on every v1 run. This sits
20442
20994
  // ABOVE the `if (!branchEl) return` exit, or a branch-less card never gets it.
@@ -21410,6 +21962,10 @@ const schedulesView = createSchedulesView({
21410
21962
  return { wf_default: 'Default', wf_auto: 'Auto', wf_memory_defrag: 'Memory defragment' }[id] || id;
21411
21963
  },
21412
21964
  onCounts: (c) => paintScheduleCounts(c),
21965
+ // Who made / changed a schedule, under the same display rule as runs (shared sign-ins only).
21966
+ personLabel: (v) => personLabel(v),
21967
+ personShown: (v) => personShown(v),
21968
+ personIni: (name, title) => personIni(name, title),
21413
21969
  // A started run opens its live monitor (the ticket id IS the runId); a finished one opens History.
21414
21970
  openRun: ({ runId, pipelineId, projectDir }) => {
21415
21971
  if (runId) { location.hash = `running/${runId}`; return; }
@@ -21846,6 +22402,12 @@ function paintRdHeader(screen, r) {
21846
22402
  pill.className = `rd-status pill-run ${family}` + (parked ? ' parked' : '');
21847
22403
  pill.querySelector('.rd-status-word').textContent = text;
21848
22404
 
22405
+ // Who started it (shared deployments only): the person chip beside the status pill, with
22406
+ // the full name — the one place that never says "you".
22407
+ screen.querySelector('.rd-row1 .person-chip')?.remove();
22408
+ const starter = personShown(r.startedBy);
22409
+ if (starter) pill.before(personChip(starter));
22410
+
21849
22411
  // Meta: project · started · elapsed · cost · step n/m · step name.
21850
22412
  const meta = screen.querySelector('.rd-meta');
21851
22413
  meta.innerHTML = '';
@@ -22075,6 +22637,7 @@ function renderPipelineTabs() {
22075
22637
  // aria-label. Costs the expanded state one extra (identical) repaint on that
22076
22638
  // one transition and nothing else.
22077
22639
  tabStatusWord(r),
22640
+ personShown(r.startedBy),
22078
22641
  ])]);
22079
22642
  if (host.dataset.tabsSig === sig) return;
22080
22643
  host.dataset.tabsSig = sig;
@@ -22116,6 +22679,13 @@ function renderPipelineTabs() {
22116
22679
 
22117
22680
  body.append(title, hint);
22118
22681
  row.append(dot, body);
22682
+ // Who started it (shared deployments only): just the initials; space is tight.
22683
+ const starter = personShown(r.startedBy);
22684
+ if (starter) {
22685
+ const ini = personIni(starter, `Started by ${starter}`);
22686
+ ini.classList.add('child-by');
22687
+ row.appendChild(ini);
22688
+ }
22119
22689
 
22120
22690
  // End-of-row marker (same slot, three mutually exclusive states):
22121
22691
  // - pending input → pulsing amber "?" (needs your answer)
@@ -23126,7 +23696,13 @@ function showView(name, param = '') {
23126
23696
  }
23127
23697
  if (name === 'stats') loadStatsView();
23128
23698
  if (name === 'schedules') { schedulesView.showTab(param); void withWorkspaces().then(() => schedulesView.load()); }
23129
- if (name === 'team-metrics') loadTeamMetricsView();
23699
+ if (name === 'team-metrics') {
23700
+ // #team-metrics/timeline opens the Timeline tab. Switching tabs inside the page only repaints:
23701
+ // the records are the same, so there is nothing to fetch again.
23702
+ tmState.tab = param === 'timeline' ? 'timeline' : 'overview';
23703
+ if (prevView === 'team-metrics' && tmState.data) { closeTlPopover(); applyTmTab(); if (tmState.tab === 'overview') renderTeamMetrics(); }
23704
+ else loadTeamMetricsView();
23705
+ } else closeTlPopover();
23130
23706
  if (name === 'team-policy') loadTeamPolicyView(param);
23131
23707
  if (name === 'workspaces') {
23132
23708
  setWsMsg('');
@@ -23517,6 +24093,7 @@ else showView(VIEW_NAMES.includes(bootView) ? bootView : 'new', VIEW_NAMES.inclu
23517
24093
  refreshAllCounts();
23518
24094
  refreshBudget();
23519
24095
  startBudgetTick();
24096
+ loadWhoami();
23520
24097
 
23521
24098
  // Ask Worca mount (§10.2 seam 1): a JS-built body-level overlay — index.html is
23522
24099
  // untouched so ui-shell's routed-view census stays at 11. No network happens here;