great-cto 3.69.0 → 3.70.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.69.0",
4
+ "version": "3.70.1",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -1266,6 +1266,11 @@ async function dispatch(req, res, url, cwd) {
1266
1266
  // shared sanitized projection only: prompts, approval tokens and artifact
1267
1267
  // bytes never cross into the browser payload.
1268
1268
  if (pathname === '/api/codex-runs') {
1269
+ if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') {
1270
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1271
+ res.end(JSON.stringify({ error: 'Unknown project; execution graphs were not read', state: 'unavailable' }));
1272
+ return true;
1273
+ }
1269
1274
  const result = listCodexRuns({ root: cwd });
1270
1275
  const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
1271
1276
  if (result.state === 'degraded') headers['X-Board-Degraded'] = encodeURIComponent(`${result.unreadable} unreadable Codex run state file(s)`);
@@ -29,7 +29,8 @@ function executionAttempts(run) {
29
29
  phase: short(a.phase), status: short(a.status), startedAt: date(a.startedAt), finishedAt: date(a.finishedAt),
30
30
  }));
31
31
  const truncated = attempts.length > 200;
32
- return { state: nodes.length ? 'recorded' : 'unknown', nodes, topology: 'unknown', truncated,
32
+ const graph = run?.executionGraph || { state: 'unavailable', complete: false, nodes: [], edges: [], snapshots: [], reason: 'No linked controller-recorded graph is available.' };
33
+ return { state: nodes.length ? 'recorded' : 'unknown', nodes, topology: graph.state === 'recorded' ? 'recorded' : 'unknown', graph, truncated,
33
34
  partial: !run || truncated || nodes.length !== window.length,
34
35
  reason: !run ? 'Stable attempt observations are unavailable without a single explicitly linked controlled run.'
35
36
  : !nodes.length ? 'No usable attempt observations are recorded.' : null };
@@ -1,4 +1,18 @@
1
1
  /* Approved task-first shell, using existing light/dark semantic tokens. */
2
+ .execution-graph{min-width:0;margin:18px 0;padding:16px;border:1px solid var(--border-strong);border-radius:6px;background:var(--bg-card)}
3
+ .execution-graph-tools{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0}
4
+ .execution-graph-canvas{overflow:auto;max-width:100%;max-height:560px;border:1px solid var(--border-strong);background:var(--bg-page)}
5
+ .execution-graph-canvas svg{display:block;max-width:none;font-family:var(--sans);font-size:12px;color:var(--text)}
6
+ .execution-node{cursor:pointer;outline:none}
7
+ .execution-node rect{fill:var(--bg-card);stroke:var(--text2);stroke-width:1.5}
8
+ .execution-node text{fill:var(--text)}
9
+ .execution-node:focus rect,.execution-node:hover rect{stroke:var(--focus-ring);stroke-width:3}
10
+ .execution-edges{stroke:var(--text2);stroke-width:1.5}
11
+ #execution-arrow path{fill:var(--text2)}
12
+ .execution-node-detail{margin:8px 0;padding:10px;border:1px solid var(--border-strong);border-radius:5px;overflow-wrap:anywhere}
13
+ .execution-node-detail summary{cursor:pointer;min-height:28px}
14
+ .execution-node-detail dt{margin-top:10px;font-weight:600}
15
+ .execution-node-detail dd{margin:4px 0;color:var(--text2);overflow-wrap:anywhere}
2
16
  #nav-tablist .nav-item{width:100%;background:transparent;border:0;border-radius:5px;font-family:var(--sans,Geist,sans-serif);font-size:14px;text-align:left;min-height:44px;color:var(--text2);cursor:pointer}
3
17
  #nav-tablist .nav-item.active{background:var(--accent-soft);color:var(--text);font-weight:600}
4
18
  #nav-tablist .nav-item:hover{background:var(--bg-muted)}
@@ -36,3 +50,66 @@
36
50
  #panel-inbox .inbox-page{max-width:1500px}.nav-item:focus-visible,.admin-subtab:focus-visible,.knowledge-file:focus-visible,.work-filter:focus-visible,.work-tab:focus-visible,.work-task-row:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
37
51
  @media(max-width:1100px){.knowledge-layout,.work-cockpit{grid-template-columns:230px minmax(0,1fr)}.admin-metrics{grid-template-columns:1fr 1fr}.admin-metrics section{border-bottom:1px solid var(--border)}}
38
52
  @media(max-width:768px){.admin-page,.work-page{padding:16px!important}.knowledge-layout,.work-cockpit{grid-template-columns:minmax(0,1fr)}#knowledge-index{border-right:0;border-bottom:1px solid var(--border);max-height:270px}.work-task-list{border-right:0;border-bottom:1px solid var(--border);max-height:240px;overflow:auto}#knowledge-document,.work-detail{padding:18px}.admin-page-heading{align-items:flex-start}.admin-metrics section{padding:15px 10px}.admin-metrics h2{font-size:23px}.admin-doc-heading{align-items:flex-start;flex-direction:column}#admin-subnav{padding:10px 16px}.admin-table{min-width:530px}.work-tabs{gap:4px}}
53
+
54
+ /* Canvas focus: the selected task owns the content area, not a nested master list. */
55
+ .canvas-task .work-page{max-width:none!important;padding:18px 24px!important}
56
+ .work-list-only .work-task-list{border:1px solid var(--border);border-radius:6px;background:var(--bg-card)}
57
+ .work-list-only .work-task-row{padding:16px 18px;display:grid;grid-template-columns:minmax(180px,2fr) minmax(130px,1fr) minmax(110px,1fr);gap:16px;align-items:center}
58
+ .work-list-only .work-task-row:last-child{border-bottom:0}
59
+ .work-task-header{display:block;margin:0 0 16px;min-width:0}
60
+ .work-task-header h2{font-size:23px;line-height:1.3;letter-spacing:-.5px;margin:12px 0 8px;overflow-wrap:anywhere}
61
+ .work-task-header p{margin:8px 0;font-size:12px;color:var(--text2)}
62
+ .work-task-header button{min-height:40px;padding:8px 12px;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-card);color:var(--text);font:inherit;cursor:pointer;flex-shrink:0}
63
+ .work-detail-only,.work-workspace{min-width:0}
64
+ .work-detail-only .work-detail{padding:0}
65
+ .canvas-task .work-tabs{margin-bottom:16px}
66
+ .work-execution-flat{padding:0!important;border:0!important;background:transparent!important}
67
+ .work-execution-flat>.execution-graph{padding:0;border:0;margin:12px 0}
68
+ .execution-graph-toolbar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:10px}
69
+ .execution-graph-toolbar h3{font-size:15px;margin:0}
70
+ .execution-graph-toolbar .execution-graph-tools{margin:0;gap:6px}
71
+ .execution-graph-tools .gate-btn{width:auto;flex:0 0 auto;min-height:40px;padding:7px 12px;font-size:12px;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-card);color:var(--text)}
72
+ .canvas-execution .execution-graph-canvas{height:clamp(360px,calc(100dvh - 340px),850px);max-height:none;border:1px solid var(--border-strong);border-radius:6px;overscroll-behavior:contain;background:var(--bg-page);touch-action:pan-x pan-y}
73
+ .canvas-task .execution-node-detail{margin:8px 0;padding:10px}
74
+ .work-execution-flat>details{margin-top:16px;border-top:1px solid var(--border);padding-top:12px}
75
+ .work-execution-flat>details>summary{min-height:40px;cursor:pointer}
76
+ .work-switcher,.work-node-inspector{color:var(--text);background:var(--bg-card);border:1px solid var(--border-strong);border-radius:8px;padding:20px;box-shadow:0 8px 32px rgb(0 0 0 / .18);max-width:calc(100vw - 24px);max-height:calc(100dvh - 32px);overflow:auto;overflow-wrap:anywhere}
77
+ .work-switcher{width:590px}
78
+ .work-switcher::backdrop{background:rgb(0 0 0 / .35)}
79
+ .work-switcher input{display:block;box-sizing:border-box;width:100%;padding:10px;margin:10px 0;border:1px solid var(--border-strong);background:var(--bg-page);color:var(--text);font:inherit;border-radius:5px}
80
+ .work-switcher button,.work-node-inspector button{min-height:40px;padding:8px 12px;background:var(--bg-card);color:var(--text);border:1px solid var(--border-strong);border-radius:5px;font:inherit;cursor:pointer}
81
+ .work-switcher [data-work-select]{display:block;width:100%;text-align:left;margin-top:8px}
82
+ .work-switcher h2,.work-node-inspector h2{font-size:18px;margin:0 0 12px}
83
+ .work-node-inspector{position:fixed;inset:260px 14px 14px auto;margin:0;width:360px;max-height:calc(100dvh - 274px);z-index:25}
84
+ .work-node-inspector .execution-node-detail{border:0;padding:0}
85
+ .work-node-inspector .execution-node-detail summary{overflow-wrap:anywhere;line-height:1.6}
86
+ .work-node-inspector dd code{white-space:normal;overflow-wrap:anywhere}
87
+ .work-node-inspector [data-graph-close]{float:right}
88
+ .canvas-nav-toggle{display:none;align-items:center;justify-content:center;min-width:40px;min-height:40px;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-card);color:var(--text);cursor:pointer}
89
+ @media(min-width:769px){
90
+ .canvas-execution .canvas-nav-toggle{display:inline-flex}
91
+ .canvas-execution:not(.canvas-navigation-expanded) .sidebar{width:64px;overflow:visible}
92
+ .canvas-execution:not(.canvas-navigation-expanded) .sidebar-brand{justify-content:center;padding:14px 10px}
93
+ .canvas-execution:not(.canvas-navigation-expanded) .brand-name,.canvas-execution:not(.canvas-navigation-expanded) #build-version,.canvas-execution:not(.canvas-navigation-expanded) .sw-meta,.canvas-execution:not(.canvas-navigation-expanded) .proj-switch>.chev,.canvas-execution:not(.canvas-navigation-expanded) .nav-label,.canvas-execution:not(.canvas-navigation-expanded) .sidebar-footer #live-label,.canvas-execution:not(.canvas-navigation-expanded) .sidebar .nav-section,.canvas-execution:not(.canvas-navigation-expanded) #nav-views-label,.canvas-execution:not(.canvas-navigation-expanded) .nav-views{display:none!important}
94
+ .canvas-execution:not(.canvas-navigation-expanded) .proj-switch{justify-content:center;padding:14px 8px}
95
+ .canvas-execution:not(.canvas-navigation-expanded) .proj-menu{left:54px;width:320px;top:0}
96
+ .canvas-execution:not(.canvas-navigation-expanded) #nav-tablist{padding:8px}
97
+ .canvas-execution:not(.canvas-navigation-expanded) #nav-tablist .nav-item{justify-content:center;position:relative;min-height:44px;padding:10px;overflow:visible}
98
+ .canvas-execution:not(.canvas-navigation-expanded) #nav-tablist .count{display:none}
99
+ .canvas-execution:not(.canvas-navigation-expanded) #nav-tablist .nav-item:is(:hover,:focus-visible)::after{content:attr(data-nav-label);position:absolute;left:50px;top:4px;z-index:50;white-space:nowrap;padding:8px 12px;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-card);color:var(--text);box-shadow:0 3px 12px rgb(0 0 0 / .12);pointer-events:none}
100
+ .canvas-execution:not(.canvas-navigation-expanded) .sidebar-footer{justify-content:center;padding:12px 8px}
101
+ }
102
+ @media(max-width:768px){
103
+ .canvas-task .work-page{padding:16px!important}
104
+ .work-task-header h2{font-size:20px}
105
+ .work-list-only .work-task-row{display:block;line-height:1.6}
106
+ .work-list-only .work-task-row small{margin-top:6px}
107
+ .work-node-inspector{inset:auto 12px 12px 12px;width:auto;max-height:65dvh}
108
+ .canvas-execution .execution-graph-canvas{height:55dvh;min-height:300px}
109
+ .canvas-execution .topbar .search-box{display:none}
110
+ .execution-graph-tools .gate-btn{min-height:44px}
111
+ }
112
+
113
+ .work-task-navigation{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.work-task-navigation>.work-meta{margin-right:auto}.canvas-task .topbar .btn-new{display:none}
114
+
115
+ .work-page .execution-graph-tools .gate-btn{background:var(--bg-card);color:var(--text);width:auto;min-height:40px}.work-node-inspector header{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px}.work-node-inspector [data-dialog-close]{flex-shrink:0}
@@ -3,6 +3,8 @@
3
3
  const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
4
4
  const aliases = { docs:'knowledge', usage:'insights/consumption', ledger:'insights/ledger', budgets:'insights/ledger', dashboard:'insights/ledger', fleet:'settings/executors', agents:'settings/executors', harness:'settings/runtime', skills:'settings/capabilities', history:'work/completed', inbox:'decisions', notifications:'settings/general', share:'settings/general' };
5
5
  function route(input) {
6
+ const taskRoute = window.GctoWork?.parseRoute?.(input);
7
+ if (taskRoute) return { canonical:taskRoute.canonical, panel:'work', primary:'work', workRoute:true };
6
8
  if (/^(?:agents|fleet)\/[a-z0-9-]+$/i.test(input)) return {canonical:input,panel:'agents',primary:'settings'};
7
9
  const canonical = aliases[input] || input;
8
10
  const panels = { work:'work','work/completed':'history',decisions:'inbox',knowledge:'knowledge',insights:'admin-outcomes','insights/outcomes':'admin-outcomes','insights/consumption':'usage','insights/ledger':'budgets',settings:'agents','settings/executors':'agents','settings/runtime':'harness','settings/capabilities':'skills','settings/general':'settings' };
@@ -76,10 +78,31 @@
76
78
  });
77
79
  } catch(error) {if(current(seq,project)&&docTicket===request) panel('knowledge-document').textContent='Document could not be read: '+error.message;}
78
80
  }
81
+ let expandedNavigation = false;
82
+ function setWorkView(view = {}) {
83
+ const detail = !!view.detail && selectedRoute.split('/')[0].split('?')[0] === 'work';
84
+ const execution = detail && view.tab === 'execution';
85
+ document.body.classList.toggle('canvas-task', detail);
86
+ document.body.classList.toggle('canvas-execution', execution);
87
+ document.body.classList.toggle('canvas-navigation-expanded', execution && expandedNavigation);
88
+ const toggle = panel('canvas-nav-toggle');
89
+ if (toggle) { toggle.hidden = !execution; toggle.setAttribute('aria-expanded', String(expandedNavigation)); toggle.setAttribute('aria-label', expandedNavigation ? 'Compact navigation' : 'Expand navigation'); toggle.title = expandedNavigation ? 'Compact navigation' : 'Expand navigation'; }
90
+ const composer = panel('work-compose')?.closest('.admin-compose');
91
+ if (composer) composer.hidden = detail;
92
+ const heading = document.querySelector('.work-page > h1');
93
+ if (heading) heading.hidden = detail;
94
+ }
95
+ function navigateWork(canonical) {
96
+ const parsed = window.GctoWork?.parseRoute?.(canonical);
97
+ if (!parsed) return;
98
+ const hash = '#/' + parsed.canonical;
99
+ if (location.hash !== hash) history.pushState(null, '', hash);
100
+ window.switchTab(parsed.canonical);
101
+ }
79
102
  function setup() {
80
103
  const nav=panel('nav-tablist'), existing={};
81
104
  document.querySelectorAll('.nav-item[data-tab]').forEach(n=>existing[n.dataset.tab]=n.querySelector('svg')?.outerHTML||'');
82
- nav.innerHTML=['work','decisions','knowledge','insights','settings'].map(key=>`<button type="button" class="nav-item${key==='work'?' active':''}" role="tab" aria-selected="${key==='work'}" data-tab="${key}" tabindex="0" onkeydown="navTabKeydown(event,this)">${existing[key]||existing[key==='knowledge'?'skills':key==='insights'?'usage':'harness']||''}<span class="nav-label">${key[0].toUpperCase()+key.slice(1)}</span>${key==='decisions'?'<span class="count" id="nav-inbox-count">0</span>':''}</button>`).join('');
105
+ nav.innerHTML=['work','decisions','knowledge','insights','settings'].map(key=>`<button type="button" class="nav-item${key==='work'?' active':''}" role="tab" aria-selected="${key==='work'}" data-tab="${key}" aria-label="${key[0].toUpperCase()+key.slice(1)}" data-nav-label="${key[0].toUpperCase()+key.slice(1)}" tabindex="0" onkeydown="navTabKeydown(event,this)">${existing[key]||existing[key==='knowledge'?'skills':key==='insights'?'usage':'harness']||''}<span class="nav-label">${key[0].toUpperCase()+key.slice(1)}</span>${key==='decisions'?'<span class="count" id="nav-inbox-count">0</span>':''}</button>`).join('');
83
106
  nav.querySelectorAll('[data-tab]').forEach(n=>n.addEventListener('click',()=>window.switchTab(n.dataset.tab)));
84
107
  const tools=panel('tools-nav');tools.hidden=true;
85
108
  const workspace=document.querySelector('.workspace');
@@ -88,6 +111,14 @@
88
111
  panel('knowledge-reload').addEventListener('click',()=>{generation++;loadKnowledge();});
89
112
  const insights=document.createElement('div');insights.id='panel-admin-outcomes';insights.className='panel';insights.setAttribute('role','tabpanel');insights.innerHTML='<div class="admin-page"><header class="admin-page-heading"><div><h1>Insights</h1><p>Recorded outcomes are separate from token consumption.</p></div><button type="button" class="gate-btn" id="outcomes-reload">Reload</button></header><div id="admin-outcomes-body" aria-live="polite"></div></div>';workspace.append(insights);
90
113
  panel('outcomes-reload').addEventListener('click',loadTaskOutcomes);
114
+ const toggle = document.createElement('button');
115
+ toggle.id = 'canvas-nav-toggle'; toggle.type = 'button'; toggle.className = 'canvas-nav-toggle'; toggle.hidden = true;
116
+ toggle.setAttribute('aria-controls', 'nav-tablist'); toggle.innerHTML = '<span aria-hidden="true">☰</span>';
117
+ workspace.querySelector('.topbar').prepend(toggle);
118
+ toggle.addEventListener('click', () => { expandedNavigation = !expandedNavigation; setWorkView({detail:true, tab:'execution'}); });
119
+ const projectSwitch = panel('proj-switch');
120
+ projectSwitch.setAttribute('role','button'); projectSwitch.tabIndex = 0; projectSwitch.setAttribute('aria-label','Choose project');
121
+ projectSwitch.addEventListener('keydown', event => { if (event.target === projectSwitch && ['Enter',' '].includes(event.key)) { event.preventDefault(); toggleProjMenu(event); } });
91
122
  const original=window.switchTab;
92
123
  window.switchTab=function(id,el){
93
124
  const agentLink=String(id).match(/^(?:agents|fleet)\/([a-z0-9-]+)$/i);
@@ -98,16 +129,19 @@
98
129
  open(0);return;
99
130
  }
100
131
  const r=route(id);
101
- if(!r) {bar.hidden=true;return original(id,el);}
132
+ if(!r) {window.GctoWork.leaveView?.();setWorkView({detail:false});bar.hidden=true;return original(id,el);}
102
133
  selectedRoute=r.canonical;
134
+ if (r.workRoute) { window.GctoWork.navigateRoute(r.canonical); const input = panel('search-input'); if(input) input.value = window.GctoWork.parseRoute(r.canonical).search; }
135
+ else { window.GctoWork.leaveView?.(); setWorkView({detail:false}); }
103
136
  const old={inbox:'decisions',budgets:'ledger',agents:'fleet','admin-outcomes':'admin-outcomes',knowledge:'knowledge'}[r.panel]||r.panel;
104
- original(old); // Keep established loaders and mutation guards.
137
+ if (r.workRoute && typeof currentTab === 'string' && currentTab === 'work') { closeMenu(false); window.GctoWork.refresh(); }
138
+ else original(old); // Keep established loaders and mutation guards; task inspection adds no view POST.
105
139
  document.querySelectorAll('.nav-item[data-tab]').forEach(n=>{const active=n.closest('#nav-tablist')&&n.dataset.tab===r.primary;n.classList.toggle('active',!!active);n.setAttribute('aria-selected',String(!!active));});
106
140
  panel('crumb-here').textContent=r.primary[0].toUpperCase()+r.primary.slice(1);
107
141
  const items=r.primary==='settings'?[['settings/executors','Executors'],['settings/runtime','Runtime'],['settings/capabilities','Capabilities'],['settings/general','General']]:r.primary==='insights'?[['insights/outcomes','Outcomes'],['insights/consumption','Consumption'],['insights/ledger','Ledger & budgets']]:[];
108
142
  bar.hidden=!items.length;bar.innerHTML=items.map(([key,label])=>`<button type="button" class="admin-subtab" data-route="${key}" aria-pressed="${r.canonical===key||(r.canonical===r.primary&&key===items[0][0])}">${label}</button>`).join('');
109
143
  bar.querySelectorAll('[data-route]').forEach(b=>b.onclick=()=>window.switchTab(b.dataset.route));
110
- history.replaceState(null,'','#/'+r.canonical);
144
+ history.replaceState(history.state,'','#/'+r.canonical);
111
145
  if(r.primary==='knowledge')loadKnowledge();
112
146
  if(r.panel==='admin-outcomes')loadTaskOutcomes();
113
147
  // Restore a working route after projection errors; an error is not an empty list.
@@ -117,6 +151,6 @@
117
151
  composer.querySelector('button[type="submit"]').textContent='Copy launch command';
118
152
  window.switchTab((location.hash||'#/work').replace(/^#\/?/,''));
119
153
  }
120
- window.GctoAdmin={route,outcomes,reset,openDocument};
154
+ window.GctoAdmin={route,outcomes,reset,openDocument,setWorkView,navigateWork};
121
155
  if(typeof document!=='undefined') setup();
122
156
  })();
@@ -69,22 +69,89 @@
69
69
  function cockpitEntries(data, category, search) {
70
70
  return data.entries.filter(e => inCategory(e, category) && (!search || `${e.title} ${e.key}`.toLowerCase().includes(search.toLowerCase())));
71
71
  }
72
- function renderExecution(entry) {
72
+ function renderExecution(entry, options = {}) {
73
73
  const roles = Array.isArray(entry.evidence) ? entry.evidence : [];
74
74
  const execution = entry.execution, nodes = Array.isArray(execution?.nodes) ? execution.nodes.slice(0, 200) : [];
75
- return '<section class="work-execution"><h3>Recorded observations</h3>'
76
- + '<p>Dependencies not recorded. This is an observation list, not an inferred execution graph. Event order does not establish dependencies.</p>'
75
+ return (options.canvas ? '<section class="work-execution work-execution-flat">' : '<section class="work-execution"><h3>Recorded observations</h3>')
76
+ + renderExecutionGraph(execution?.graph, options)
77
+ + (execution?.graph?.state === 'recorded' ? '' : '<p>Dependencies not recorded. This is an observation list, not an inferred execution graph. Event order does not establish dependencies.</p>')
77
78
  + `<p>Recorded stage: ${esc(entry.stage || 'not recorded')}</p>`
78
79
  + (execution?.reason ? `<p>${esc(execution.reason)}</p>` : '')
79
80
  + (execution?.truncated ? '<p class="work-warning">Showing the latest 200 recorded attempts; earlier observations are truncated.</p>' : execution?.partial ? '<p class="work-warning">Attempt observations are incomplete.</p>' : '')
81
+ + (options.canvas && entry.inspector?.partial ? '<p class="work-warning">Activity window is incomplete.</p>' : '')
82
+ + (options.canvas && entry.inspector?.reason ? `<p class="work-warning">${esc(entry.inspector.reason)}</p>` : '')
83
+ + (options.canvas && !entry.inspector ? '<p class="work-meta">Linked activity unavailable. Absence of events is not proof of inactivity.</p>' : '')
84
+ + (options.canvas ? '<details class="work-attempt-details"><summary>Recorded observations & technical details</summary>' : '')
80
85
  + (nodes.length ? '<ul class="work-observations" aria-label="Recorded execution attempts">' + nodes.map(n => `<li class="work-observation" data-attempt-id="${esc(n.attemptId)}"><strong>${esc(n.role)} · attempt ${esc(n.number || 'unknown')}</strong>`
81
86
  + `<p>${esc(n.host || 'Host not recorded')} · phase: ${esc(n.phase || 'not recorded')}</p><p>Controller recorded status: ${esc(n.status || 'unknown')}; not current verification or acceptance.</p>`
82
87
  + `<p>Run: ${esc(n.runId)} · Attempt: ${esc(n.attemptId)}</p><p>Started: ${esc(n.startedAt || 'not recorded')} · Finished: ${esc(n.finishedAt || 'not recorded')}</p></li>`).join('') + '</ul>' : '')
83
88
  + (roles.length ? '<ul class="work-observations">' + roles.map(role => `<li class="work-observation">${esc(role.label)} · recorded role observation; acceptance is not inferred</li>`).join('') + '</ul>' : '<p>No role observations recorded.</p>')
84
89
  + (entry.inspector ? renderInspector(entry.inspector).replace('<details ', '<details open ') : '<p>Linked activity unavailable. Absence of events is not proof of inactivity.</p>')
90
+ + (options.canvas ? '</details>' : '')
85
91
  + '</section>';
86
92
  }
93
+ function renderExecutionGraph(graph, options = {}) {
94
+ if (graph?.state !== 'recorded' || !Array.isArray(graph.nodes) || !graph.nodes.length)
95
+ return `<p class="work-meta">${esc(graph?.reason || 'Controller-recorded graph unavailable. Native and legacy observations remain partial.')}</p>`;
96
+ if (graph.nodes.length > 512 || !Array.isArray(graph.edges) || graph.edges.length > 2048) return '<p>Graph exceeds the display limit.</p>';
97
+ const horizontal = options.canvas && options.direction !== 'tb';
98
+ const nodes = graph.nodes, ids = new Map(nodes.map((n, i) => [n.id, i]));
99
+ if (ids.size !== nodes.length || graph.edges.some(e => !ids.has(e.from) || !ids.has(e.to) || ids.get(e.from) >= ids.get(e.to)))
100
+ return '<p>Graph dependencies cannot be displayed safely.</p>';
101
+ const levels = new Map(nodes.map(n => [n.id, 0]));
102
+ const parents = new Map(nodes.map(n => [n.id, []]));
103
+ for (const e of graph.edges) parents.get(e.to).push(e);
104
+ const rows = new Map(), positions = new Map();
105
+ for (const n of nodes) {
106
+ const level = Math.max(0, ...parents.get(n.id).map(e => levels.get(e.from) + 1));
107
+ levels.set(n.id, level);
108
+ const row = rows.get(level) || 0; rows.set(level, row + 1);
109
+ positions.set(n.id, horizontal ? { x: 24 + level * 272, y: 24 + row * 112 } : { x: 24 + row * 236, y: 24 + level * 112 });
110
+ }
111
+ const width = horizontal ? (Math.max(...levels.values()) + 1) * 272 : Math.max(500, Math.max(...rows.values()) * 236 + 24), height = horizontal ? Math.max(240, Math.max(...rows.values()) * 112 + 24) : (Math.max(...levels.values()) + 1) * 112 + 24;
112
+ const edges = graph.edges.map(e => {
113
+ const a = positions.get(e.from), b = positions.get(e.to);
114
+ if (horizontal) return `<path d="M${a.x + 208} ${a.y + 39} C${a.x + 240} ${a.y + 39},${b.x - 32} ${b.y + 39},${b.x} ${b.y + 39}" marker-end="url(#execution-arrow)"><title>${esc(e.kind)}: ${esc(e.from)} to ${esc(e.to)}</title></path>`;
115
+ return `<path d="M${a.x + 104} ${a.y + 78} C${a.x + 104} ${a.y + 98},${b.x + 104} ${b.y - 20},${b.x + 104} ${b.y}" marker-end="url(#execution-arrow)"><title>${esc(e.kind)}: ${esc(e.from)} to ${esc(e.to)}</title></path>`;
116
+ }).join('');
117
+ const short = value => String(value || '').length > 27 ? String(value).slice(0, 26) + '…' : String(value || '');
118
+ const boxes = nodes.map(n => {
119
+ const p = positions.get(n.id);
120
+ return `<g class="execution-node" role="button" tabindex="0" data-graph-node="${esc(n.id)}" aria-label="${esc(n.kind)} ${esc(n.role || '')}: ${esc(n.status)}. Open recorded details." transform="translate(${p.x},${p.y})">${options.canvas ? `<title>${esc(n.kind)} · ${esc(n.role || 'controller')} · ${esc(n.status)}</title>` : ''}<rect width="208" height="78" rx="6"/><text x="12" y="23">${esc(short(n.role || n.kind))}</text><text x="12" y="45">${options.canvas ? esc(short(`${n.kind} · ${n.status}`)) : `${esc(n.kind)} · ${esc(n.status)}`}</text><text x="12" y="64">${esc(n.host || 'controller')}</text></g>`;
121
+ }).join('');
122
+ const details = nodes.map(n => `<details class="execution-node-detail" data-graph-detail="${esc(n.id)}"><summary>${esc(n.id)} · ${esc(n.kind)} · ${esc(n.role || 'controller')} · ${esc(n.status)}</summary><dl>`
123
+ + [['Attempt', n.attemptId], ['Host', n.host], ['Recorded at', n.at], ['Verdict', n.verdict], ['Wave', n.waveId], ['Gates', (n.gates || []).join(', ')],
124
+ ['Workflow snapshot SHA-256', n.graphDigest], ['Input receipt SHA-256', n.inputDigest], ['Output receipt SHA-256', n.outputDigest],
125
+ [n.kind === 'reuse' ? 'Historical report SHA-256 (worker not invoked)' : 'Stage context SHA-256', n.contextDigest], ['Result SHA-256', n.resultDigest]]
126
+ .map(([key, value]) => `<dt>${esc(key)}</dt><dd><code>${esc(value || 'Not recorded')}</code></dd>`).join('')
127
+ + `<dt>Recorded predecessors</dt><dd>${parents.get(n.id).map(e => `${esc(e.from)} (${esc(e.kind)})`).join(', ') || 'None recorded; not proof of complete external inputs'}</dd></dl></details>`).join('');
128
+ return `<section class="execution-graph" aria-label="Controller-recorded execution graph"><h3>Controller-recorded graph</h3><p>${nodes.length} nodes · ${graph.edges.length} recorded edges · ${graph.snapshots?.length || 0} workflow snapshots. ${graph.complete ? 'Controller history recorded from run creation.' : 'Partial history: missing edges are not inferred.'}</p>`
129
+ + `<p class="work-meta">Dispatch, input, verification, gate and join edges describe controller observations. Intent is not proof of a live worker; recorded approval is not deployment or task acceptance. Historical reuse does not invoke a worker. Stage context digests exclude separately admitted domain packets.</p>`
130
+ + (graph.reason ? `<p class="work-warning">${esc(graph.reason)}</p>` : '')
131
+ + `<div class="execution-graph-tools execution-graph-toolbar" role="group" aria-label="Graph zoom"><button type="button" class="gate-btn" data-graph-scale="0.75">Zoom out</button><button type="button" class="gate-btn" data-graph-scale="1.333333">Zoom in</button><button type="button" class="gate-btn" data-graph-scale="reset">Reset zoom</button>${options.canvas ? `<button type="button" class="gate-btn" data-graph-scale="fit">Fit graph</button><button type="button" class="gate-btn" data-graph-direction="lr" aria-pressed="${horizontal}">Left to right</button><button type="button" class="gate-btn" data-graph-direction="tb" aria-pressed="${!horizontal}">Top to bottom</button>` : ''}</div>`
132
+ + `<div class="execution-graph-canvas" tabindex="0" aria-label="Scrollable graph. Tab to a node and press Enter for details."><svg data-graph-layout="${horizontal ? 'lr' : 'tb'}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="group" aria-label="Recorded execution dependencies"><defs><marker id="execution-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8"/></marker></defs><g class="execution-edges" fill="none">${edges}</g>${boxes}</svg></div>`
133
+ + (options.canvas ? '<details class="execution-node-list"><summary>Recorded node details · keyboard list</summary>' : '<h3>Recorded node details</h3>') + `<p class="work-meta">Use the graph or expand a node below. Digests bind recorded inputs, not correctness.</p>${details}${options.canvas ? '</details>' : ''}</section>`;
134
+ }
135
+ function graphInteraction(target) {
136
+ const direction = target.closest('[data-graph-direction]');
137
+ if (config?.canvas && ['lr', 'tb'].includes(direction?.dataset.graphDirection)) { graphDirection = direction.dataset.graphDirection; closeWorkDialog(false); paint(); document.querySelector?.('[data-graph-direction][aria-pressed="true"]')?.focus(); return true; }
138
+ const node = target.closest('[data-graph-node]');
139
+ if (node?.dataset.graphNode) {
140
+ const detail = [...document.querySelectorAll('[data-graph-detail]')].find(el => el.dataset.graphDetail === node.dataset.graphNode);
141
+ if (detail && config?.canvas) openNodeInspector(detail, node);
142
+ else if (detail) { detail.open = true; detail.querySelector('summary')?.focus(); detail.scrollIntoView({ block: 'nearest' }); }
143
+ return true;
144
+ }
145
+ const scale = target.closest('[data-graph-scale]');
146
+ if (!scale?.dataset.graphScale) return false;
147
+ const svg = scale.closest('.execution-graph').querySelector('svg'), bounds = svg.viewBox.baseVal;
148
+ const fit = scale.dataset.graphScale === 'fit' ? Math.min(1, Math.max(0.05, (scale.closest('.execution-graph').querySelector('.execution-graph-canvas').clientWidth - 2) / bounds.width)) : null;
149
+ const next = fit ?? (scale.dataset.graphScale === 'reset' ? 1 : Math.min(2, Math.max(0.25, Number(svg.dataset.zoom || 1) * Number(scale.dataset.graphScale))));
150
+ svg.dataset.zoom = String(next); svg.setAttribute('width', String(bounds.width * next)); svg.setAttribute('height', String(bounds.height * next));
151
+ return true;
152
+ }
87
153
  function renderCockpit(data, options = {}) {
154
+ if (options.canvas) return renderWorkspace(data, options);
88
155
  const category = categories.some(([key]) => key === options.category) ? options.category : 'active';
89
156
  const tab = ['overview', 'execution', 'evidence'].includes(options.tab) ? options.tab : 'overview';
90
157
  const rows = cockpitEntries(data, category, options.search || '');
@@ -104,6 +171,55 @@
104
171
  + `<div class="work-cockpit">${list}<section class="work-detail" aria-label="Selected task details">${tabs}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section></div>`
105
172
  + (data.sessions.length ? `<details><summary>Claude sessions (${data.sessions.length}) · task linkage not recorded</summary>${data.sessions.map(s => `<p>${esc(s.session)} · ${esc(s.state)} · ${esc(s.reason || '')}</p>`).join('')}</details>` : '');
106
173
  }
174
+ const taskTabs = ['overview', 'execution', 'evidence'];
175
+ function parseRoute(input) {
176
+ if (typeof input !== 'string' || input.length > 4096 || /[\u0000-\u001f\u007f]/.test(input)) return null;
177
+ const [path, query, extra] = input.split('?');
178
+ if (extra !== undefined) return null;
179
+ const params = new URLSearchParams(query || '');
180
+ if ([...params.keys()].some(key => !['category', 'search'].includes(key) || params.getAll(key).length !== 1)) return null;
181
+ const category = params.get('category') || (path === 'work/completed' ? 'completed' : 'active'), search = params.get('search') || '';
182
+ if (!categories.some(([key]) => key === category) || search.length > 1000 || /[\u0000-\u001f\u007f]/.test(search)) return null;
183
+ let selectedKey = null, tab = 'overview';
184
+ if (path !== 'work' && path !== 'work/completed') {
185
+ const match = path.match(/^work\/task\/([^/]+)\/(overview|execution|evidence)$/);
186
+ if (!match) return null;
187
+ try { selectedKey = decodeURIComponent(match[1]); } catch { return null; }
188
+ if (!selectedKey || selectedKey.length > 512 || /[\u0000-\u001f\u007f]/.test(selectedKey)) return null;
189
+ tab = match[2];
190
+ }
191
+ return { canonical: workspaceRoute(selectedKey, tab, category, search), selectedKey, tab, category, search };
192
+ }
193
+ function workspaceRoute(key, selectedTab, selectedCategory, search) {
194
+ const params = new URLSearchParams();
195
+ if (selectedCategory !== 'active') params.set('category', selectedCategory);
196
+ if (search) params.set('search', search);
197
+ return (key ? `work/task/${encodeURIComponent(key)}/${selectedTab}` : 'work') + (params.size ? '?' + params : '');
198
+ }
199
+ function taskRows(rows, selected = null) {
200
+ return rows.map(e => `<button type="button" class="work-task-row" data-work-select="${esc(e.key)}" aria-current="${e.key === selected}"><strong>${esc(e.title)}</strong><span>${esc(phaseLabel(e))}</span><small>${esc(e.host || 'Host not linked')} · ${esc(e.stage || 'Stage not recorded')}</small></button>`).join('');
201
+ }
202
+ function renderWorkspace(data, options) {
203
+ const category = categories.some(([key]) => key === options.category) ? options.category : 'active';
204
+ const tab = taskTabs.includes(options.tab) ? options.tab : 'overview';
205
+ const rows = cockpitEntries(data, category, options.search || '');
206
+ const feedback = data.sources.filter(s => ['degraded', 'unavailable'].includes(s.health)).map(s => `<p class="work-warning">${esc(s.id)}: ${esc(s.reason || s.health)}</p>`).join('');
207
+ const observation = `<p class="work-meta">Observed ${esc(data.observedAt)} · ${data.health === 'current' ? 'Sources read' : 'Sources incomplete'}. Recorded activity is not proof of current liveness or task acceptance.</p>`;
208
+ if (!options.selectedKey) {
209
+ const toolbar = '<div class="work-filters" role="group" aria-label="Task status filters">' + categories.map(([key, label]) => `<button type="button" class="work-filter" data-work-filter="${key}" aria-pressed="${category === key}">${label} <span>${cockpitEntries(data, key, options.search || '').length}</span></button>`).join('') + '</div>';
210
+ return `<section class="work-workspace work-list-only" aria-label="Project work list">${toolbar}${observation}${feedback}` + (rows.length ? `<div class="work-task-list" role="group" aria-label="Project tasks">${taskRows(rows)}</div>` : `<p>${data.health !== 'current' || feedback ? 'No readable entries match this view; sources are incomplete.' : options.search ? 'No entries match the search.' : 'No work records match this filter.'}</p>`) + '</section>';
211
+ }
212
+ const selected = data.entries.find(e => e.key === options.selectedKey);
213
+ const back = '<button type="button" class="work-back" data-work-back>← Work</button>';
214
+ if (!selected) return `<section class="work-workspace work-detail-only">${back}${observation}${feedback}<h2>Selected task is unavailable</h2><p>The requested task is not in the readable records for this project. Another task will not be substituted.</p></section>`;
215
+ const tabs = '<div class="work-tabs" role="tablist" aria-label="Selected task">' + taskTabs.map(name => `<button type="button" class="work-tab" id="work-tab-${name}" data-work-tab="${name}" role="tab" aria-selected="${tab === name}" aria-controls="work-detail-panel" tabindex="${tab === name ? 0 : -1}">${name[0].toUpperCase() + name.slice(1)}</button>`).join('') + '</div>';
216
+ const detail = tab === 'execution' ? renderExecution(selected, { canvas: true, direction: options.direction }) : tab === 'evidence'
217
+ ? (Array.isArray(selected.evidenceSections) && selected.evidenceSections.length ? renderEvidence(selected.evidenceSections).replace('<details ', '<details open ') : '<p>Evidence unavailable. No verification or acceptance is inferred.</p>')
218
+ : render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
219
+ const runLabel = selected.title.match(/^Controlled run ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
220
+ const title = runLabel ? `Controlled run <small class="work-meta work-run-identifier">${esc(runLabel[1].slice(0, 8))}</small>` : esc(selected.title);
221
+ return `<section class="work-workspace work-detail-only" data-selected-work="${esc(selected.key)}"><header class="work-task-header"><div class="work-task-navigation">${back}<span class="work-meta">${esc(selected.kind)} · ${esc(selected.host || 'Host not linked')}</span><button type="button" data-work-switch>Switch task</button><button type="button" data-work-previous${rows.findIndex(e => e.key === selected.key) > 0 ? '' : ' disabled'} aria-label="Previous task in current filter">Previous</button><button type="button" data-work-next${rows.findIndex(e => e.key === selected.key) >= 0 && rows.findIndex(e => e.key === selected.key) < rows.length - 1 ? '' : ' disabled'} aria-label="Next task in current filter">Next</button></div><h2>${title}</h2><p>${esc(phaseLabel(selected))}</p></header>${tabs}${feedback}${tab === 'overview' ? '' : observation}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section>`;
222
+ }
107
223
  function renderEvidence(sections) {
108
224
  if (!Array.isArray(sections)) return '';
109
225
  return '<details data-work-detail="evidence"><summary>Task evidence · read-only</summary>'
@@ -152,40 +268,125 @@
152
268
  status('Draft PR publication recorded. Merge and release remain separate decisions.'); await refresh();
153
269
  } catch (error) { if (project === config.project()) { status(error.message); await refresh(); } }
154
270
  }
155
- let config, snapshot, generation = 0, filter = '', selectedKey = null, category = 'active', tab = 'overview';
271
+ let config, snapshot, snapshotProject = null, generation = 0, filter = '', selectedKey = null, category = 'active', tab = 'overview';
272
+ let graphDirection = 'lr';
273
+ let activeDialog = null, dialogOpener = null, dialogReturn = null, dialogEscape = null, pendingPaint = false, listPosition = null;
156
274
  const status = message => { document.getElementById('work-feedback').textContent = message; };
275
+ function notifyView() { config?.onViewChange?.({ detail: !!selectedKey, tab }); }
276
+ function navigateRoute(input) {
277
+ const route = parseRoute(input);
278
+ if (!route) return false;
279
+ closeWorkDialog(false);
280
+ const wasDetail = !!selectedKey;
281
+ if (selectedKey !== route.selectedKey) graphDirection = 'lr';
282
+ selectedKey = route.selectedKey; tab = route.tab; category = route.category; filter = route.search;
283
+ if (snapshot && snapshotProject !== config?.project()) { snapshot = null; previews.clear(); document.getElementById('work-list').innerHTML = ''; document.getElementById('work-list').textContent = 'Loading selected project…'; }
284
+ notifyView(); paint();
285
+ if (wasDetail && !selectedKey) restoreListPosition();
286
+ return true;
287
+ }
288
+ function rememberListPosition() {
289
+ const list = document.getElementById('work-list'), scroller = list.closest?.('.panel');
290
+ listPosition = { project: config.project(), category, filter, key: selectedKey, top: scroller?.scrollTop || 0 };
291
+ }
292
+ function restoreListPosition() {
293
+ if (!listPosition || listPosition.project !== config.project() || listPosition.category !== category || listPosition.filter !== filter) return;
294
+ const list = document.getElementById('work-list'), scroller = list.closest?.('.panel');
295
+ if (scroller) scroller.scrollTop = listPosition.top;
296
+ [...document.querySelectorAll('[data-work-select]')].find(n => n.dataset.workSelect === listPosition.key)?.focus({ preventScroll: true });
297
+ }
298
+ function changeWorkspace() {
299
+ closeWorkDialog(false); notifyView(); paint();
300
+ config.navigate?.(workspaceRoute(selectedKey, tab, category, filter));
301
+ }
302
+ function closeWorkDialog(restore = true) {
303
+ if (!activeDialog) return;
304
+ const dialog = activeDialog, opener = dialogOpener, returnTo = dialogReturn;
305
+ activeDialog = null; dialogOpener = null; dialogReturn = null;
306
+ if (dialogEscape) { document.removeEventListener('keydown', dialogEscape, true); dialogEscape = null; }
307
+ if (dialog.open) dialog.close();
308
+ dialog.remove();
309
+ if (pendingPaint) { pendingPaint = false; paint(); }
310
+ if (!restore || !returnTo || returnTo.project !== config.project() || returnTo.task !== selectedKey) return;
311
+ const replacement = returnTo.node
312
+ ? [...document.querySelectorAll('[data-graph-node]')].find(node => node.dataset.graphNode === returnTo.node)
313
+ : returnTo.switcher ? document.querySelectorAll('[data-work-switch]')[0] : null;
314
+ if (opener?.isConnected) opener.focus();
315
+ else replacement?.focus();
316
+ }
317
+ function leaveView() { pendingPaint = false; closeWorkDialog(false); }
318
+ function makeWorkDialog(className, title, opener, content, modal) {
319
+ closeWorkDialog(false);
320
+ const dialog = document.createElement('dialog'); dialog.className = className;
321
+ dialog.setAttribute('aria-label', title);
322
+ dialog.innerHTML = `<header><h2>${esc(title)}</h2><button type="button" data-dialog-close aria-label="Close ${esc(title)}">Close</button></header>${content}`;
323
+ document.body.append(dialog); activeDialog = dialog; dialogOpener = opener;
324
+ dialogReturn = { project: config.project(), task: selectedKey, node: opener?.dataset?.graphNode || null, switcher: Object.prototype.hasOwnProperty.call(opener?.dataset || {}, 'workSwitch') };
325
+ dialogEscape = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeWorkDialog(); } };
326
+ document.addEventListener('keydown', dialogEscape, true);
327
+ dialog.addEventListener('cancel', event => { event.preventDefault(); closeWorkDialog(); });
328
+ dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeWorkDialog(); } });
329
+ dialog.addEventListener('click', event => { if (event.target.closest('[data-dialog-close]')) closeWorkDialog(); });
330
+ dialog.addEventListener('close', () => { if (activeDialog === dialog) closeWorkDialog(); });
331
+ if (modal) dialog.showModal(); else dialog.show();
332
+ return dialog;
333
+ }
334
+ function openTaskSwitcher(opener) {
335
+ const rows = cockpitEntries(snapshot, category, filter);
336
+ const dialog = makeWorkDialog('work-switcher', 'Switch task', opener, '<label>Find a task<input type="search" data-switch-search placeholder="Title or task ID"></label><p class="work-meta">Tasks in the current project and list filter.</p><div data-switch-rows>' + taskRows(rows, selectedKey) + '</div><p data-switch-empty hidden>No matching task.</p>', true);
337
+ const input = dialog.querySelector('[data-switch-search]'); input.focus();
338
+ input.addEventListener('input', () => { let count = 0; dialog.querySelectorAll('[data-work-select]').forEach(button => { const entry = rows.find(e => e.key === button.dataset.workSelect); button.hidden = !`${entry.title} ${entry.key}`.toLowerCase().includes(input.value.toLowerCase()); if (!button.hidden) count++; }); dialog.querySelector('[data-switch-empty]').hidden = count > 0; });
339
+ dialog.addEventListener('click', event => { const button = event.target.closest('[data-work-select]'); if (button && snapshot?.entries.some(e => e.key === button.dataset.workSelect) && snapshotProject === config.project()) { selectedKey = button.dataset.workSelect; tab = 'overview'; changeWorkspace(); document.querySelector('[data-work-tab]')?.focus(); } });
340
+ }
341
+ function openNodeInspector(detail, opener) {
342
+ document.querySelectorAll('[data-graph-detail][open]').forEach(node => { node.open = false; });
343
+ const content = detail.outerHTML.replace('<details ', '<details open ');
344
+ const dialog = makeWorkDialog('work-node-inspector', 'Recorded node details', opener, '<p class="work-meta">Recorded inputs, not proof of correctness, current activity or acceptance.</p>' + content, false);
345
+ dialog.querySelector('summary')?.focus();
346
+ }
157
347
  async function refresh() {
158
348
  const seq = ++generation, project = config.project();
159
349
  try {
160
350
  const data = await config.read(`/api/work${project ? '?project=' + encodeURIComponent(project) : ''}`);
161
351
  if (seq !== generation || project !== config.project()) return;
162
352
  if (!data || data.error || data.schemaVersion !== 1) throw Error(data?.error || 'Cannot read work state. Check the board connection and refresh.');
163
- if (snapshot?.revision === data.revision) return;
164
- snapshot = data; paint();
353
+ if (snapshotProject === project && snapshot?.revision === data.revision) return;
354
+ snapshot = data; snapshotProject = project;
355
+ if (config.canvas && activeDialog?.open) { pendingPaint = true; previews.clear(); } else paint();
165
356
  } catch (error) {
166
357
  if (seq !== generation || project !== config.project()) return;
167
- snapshot = null;
358
+ snapshot = null; snapshotProject = null; pendingPaint = false; closeWorkDialog(false);
168
359
  for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = error.message;
169
360
  }
170
361
  }
171
362
  function paint() {
172
- if (!snapshot) return;
363
+ if (!snapshot || (config?.canvas && snapshotProject !== config.project())) return;
173
364
  previews.clear(); // Repaint removes the reviewed diff and its confirmation controls.
174
365
  const expanded = [...document.querySelectorAll('.work-card details[open]')].map(d => `${d.closest('[data-entry-key]').dataset.entryKey}:${d.dataset.workDetail}`);
175
366
  const focused = document.activeElement;
176
367
  const focusKey = focused?.dataset?.workKey, focusAction = focused?.dataset?.workAction;
177
368
  const focusSelect = focused?.dataset?.workSelect, focusFilter = focused?.dataset?.workFilter, focusTab = focused?.dataset?.workTab;
178
- document.getElementById('work-list').innerHTML = renderCockpit(snapshot, { search: filter, selectedKey, category, tab });
369
+ const focusNode = focused?.dataset?.graphNode;
370
+ const oldCanvas = config.canvas ? document.querySelector?.('.execution-graph-canvas') : null;
371
+ const oldSvg = oldCanvas?.querySelector('svg'), oldZoom = oldSvg ? Number(oldSvg.dataset.zoom || 1) : 1;
372
+ const graphScroll = oldCanvas?.closest('[data-selected-work]')?.dataset.selectedWork === selectedKey && oldSvg?.dataset.graphLayout === graphDirection ? { left: oldCanvas.scrollLeft, top: oldCanvas.scrollTop } : null;
373
+ document.getElementById('work-list').innerHTML = renderCockpit(snapshot, { search: filter, selectedKey, category, tab, canvas: !!config.canvas, direction: graphDirection });
179
374
  document.getElementById('history-list').innerHTML = render(snapshot, true, filter);
375
+ if (graphScroll) {
376
+ const canvas = document.querySelector?.('.execution-graph-canvas'), svg = canvas?.querySelector('svg');
377
+ if (svg && Number.isFinite(oldZoom) && oldZoom > 0) { const bounds = svg.viewBox.baseVal; svg.dataset.zoom = String(oldZoom); svg.setAttribute('width', String(bounds.width * oldZoom)); svg.setAttribute('height', String(bounds.height * oldZoom)); canvas.scrollLeft = graphScroll.left; canvas.scrollTop = graphScroll.top; }
378
+ }
180
379
  for (const card of document.querySelectorAll('[data-entry-key]')) for (const detail of card.querySelectorAll('details'))
181
380
  if (expanded.includes(`${card.dataset.entryKey}:${detail.dataset.workDetail}`)) detail.open = true;
182
381
  if (focusKey) [...document.querySelectorAll('[data-work-key]')].find(b => b.dataset.workKey === focusKey && b.dataset.workAction === focusAction)?.focus();
183
382
  else if (focusSelect) [...document.querySelectorAll('[data-work-select]')].find(b => b.dataset.workSelect === focusSelect)?.focus();
184
383
  else if (focusFilter) [...document.querySelectorAll('[data-work-filter]')].find(b => b.dataset.workFilter === focusFilter)?.focus();
185
384
  else if (focusTab) [...document.querySelectorAll('[data-work-tab]')].find(b => b.dataset.workTab === focusTab)?.focus();
385
+ else if (focusNode) [...document.querySelectorAll('[data-graph-node]')].find(n => n.dataset.graphNode === focusNode)?.focus({ preventScroll: true });
186
386
  }
187
387
  function reset() {
188
- generation++; snapshot = null; previews.clear(); selectedKey = null; category = 'active'; tab = 'overview'; filter = ''; status('');
388
+ closeWorkDialog(false); pendingPaint = false; listPosition = null; graphDirection = 'lr';
389
+ generation++; snapshotProject = null; snapshot = null; previews.clear(); selectedKey = null; category = 'active'; tab = 'overview'; filter = ''; status(''); notifyView();
189
390
  for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = 'Loading project work…';
190
391
  refresh();
191
392
  }
@@ -207,15 +408,24 @@
207
408
  document.getElementById('work-command').textContent = ''; status('');
208
409
  });
209
410
  for (const id of ['work-list', 'history-list']) document.getElementById(id).addEventListener('click', event => {
210
- if (!snapshot) return;
411
+ if (config.canvas && snapshotProject !== config.project()) return;
412
+ if (graphInteraction(event.target)) return;
413
+ if (!snapshot || (config.canvas && snapshotProject !== config.project())) return;
414
+ if (config.canvas) {
415
+ if (event.target.closest('[data-work-back]')) { selectedKey = null; tab = 'overview'; changeWorkspace(); restoreListPosition(); return; }
416
+ const switcher = event.target.closest('[data-work-switch]');
417
+ if (switcher) { openTaskSwitcher(switcher); return; }
418
+ const direction = event.target.closest('[data-work-previous]') ? -1 : event.target.closest('[data-work-next]') ? 1 : 0;
419
+ if (direction) { const rows = cockpitEntries(snapshot, category, filter), index = rows.findIndex(e => e.key === selectedKey), next = index >= 0 ? rows[index + direction] : null; if (next) { selectedKey = next.key; tab = 'overview'; changeWorkspace(); } return; }
420
+ }
211
421
  const button = event.target.closest('[data-work-action]');
212
422
  if (!button?.dataset.workAction) {
213
423
  const selection = event.target.closest('[data-work-select]');
214
424
  const filterButton = event.target.closest('[data-work-filter]');
215
425
  const tabButton = event.target.closest('[data-work-tab]');
216
- if (selection?.dataset.workSelect && snapshot.entries.some(e => e.key === selection.dataset.workSelect)) { selectedKey = selection.dataset.workSelect; tab = 'overview'; paint(); }
217
- else if (categories.some(([key]) => key === filterButton?.dataset.workFilter)) { category = filterButton.dataset.workFilter; selectedKey = null; tab = 'overview'; paint(); }
218
- else if (['overview', 'execution', 'evidence'].includes(tabButton?.dataset.workTab)) { tab = tabButton.dataset.workTab; paint(); }
426
+ if (selection?.dataset.workSelect && snapshot.entries.some(e => e.key === selection.dataset.workSelect)) { if (config.canvas) { rememberListPosition(); listPosition.key = selection.dataset.workSelect; } selectedKey = selection.dataset.workSelect; tab = 'overview'; if (config.canvas) changeWorkspace(); else paint(); }
427
+ else if (categories.some(([key]) => key === filterButton?.dataset.workFilter)) { category = filterButton.dataset.workFilter; selectedKey = null; tab = 'overview'; if (config.canvas) changeWorkspace(); else paint(); }
428
+ else if (['overview', 'execution', 'evidence'].includes(tabButton?.dataset.workTab)) { tab = tabButton.dataset.workTab; if (config.canvas) changeWorkspace(); else paint(); }
219
429
  return;
220
430
  }
221
431
  const entry = snapshot.entries.find(e => e.key === button.dataset.workKey);
@@ -227,16 +437,18 @@
227
437
  else options.openIssue(entry.issueIds[0], action.action === 'review_decision');
228
438
  });
229
439
  document.getElementById('work-list').addEventListener('keydown', event => {
440
+ if (config.canvas && snapshotProject !== config.project()) return;
441
+ if (['Enter', ' '].includes(event.key) && event.target.closest('[data-graph-node]')) { event.preventDefault(); graphInteraction(event.target); return; }
230
442
  const button = event.target.closest('[data-work-tab]');
231
443
  if (!button || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
232
444
  const tabs = ['overview', 'execution', 'evidence'], current = tabs.indexOf(button.dataset.workTab);
233
445
  if (current < 0) return;
234
446
  event.preventDefault();
235
447
  tab = event.key === 'Home' ? tabs[0] : event.key === 'End' ? tabs[2] : tabs[(current + (event.key === 'ArrowRight' ? 1 : 2)) % 3];
236
- paint();
448
+ if (config.canvas) changeWorkspace(); else paint();
237
449
  [...document.querySelectorAll('[data-work-tab]')].find(b => b.dataset.workTab === tab)?.focus();
238
450
  });
239
451
  setInterval(() => { if (!document.hidden && options.visible()) refresh(); }, 10000);
240
452
  }
241
- window.GctoWork = { bind, refresh, reset, render, renderCockpit, commandFor, publicationCommand, getSnapshot() { return snapshot; }, showCompleted() { category = 'completed'; selectedKey = null; tab = 'overview'; paint(); }, search(value) { filter = value; paint(); } };
453
+ window.GctoWork = { bind, refresh, reset, leaveView, parseRoute, navigateRoute, render, renderCockpit, renderExecutionGraph, commandFor, publicationCommand, getSnapshot() { return snapshot; }, showCompleted() { category = 'completed'; selectedKey = null; tab = 'overview'; notifyView(); paint(); }, search(value) { filter = value; if (config?.canvas) { if (!selectedKey) changeWorkspace(); } else paint(); } };
242
454
  })();