@worca/app 1.4.0 → 1.5.0-rc.3
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/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- 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
|
-
|
|
9404
|
-
|
|
9405
|
-
|
|
9406
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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;
|