graphlin 0.1.1 → 0.1.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.
@@ -157,6 +157,23 @@ export function liveNodeChanges(previous, next, eligible) {
157
157
  };
158
158
  }
159
159
 
160
+ export function filterDiagram(graph, query = '', kinds = null) {
161
+ if (!query && kinds === null) return graph;
162
+ const needle = query.toLowerCase();
163
+ const nodes = graph.nodes.filter(node => (kinds === null || kinds.has(node.kind)) && node.label.toLowerCase().includes(needle));
164
+ const visible = new Set(nodes.map(node => node.id));
165
+ return { ...graph, nodes, edges: graph.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target)) };
166
+ }
167
+
168
+ function isSearchTypingTarget(target) {
169
+ for (let element = target; element; element = element.parentElement) {
170
+ if (['input', 'textarea', 'select', 'dialog'].includes(element.tagName?.toLowerCase()) ||
171
+ element.isContentEditable || ['textbox', 'combobox', 'searchbox'].includes(element.getAttribute?.('role')) ||
172
+ ['true', '', 'plaintext-only'].includes(element.getAttribute?.('contenteditable'))) return true;
173
+ }
174
+ return false;
175
+ }
176
+
160
177
  function nodeTitleWidth(shapeName) {
161
178
  return { queue: 132, component: 142, parallelogram: 144, diamond: 140 }[shapeName] || 158;
162
179
  }
@@ -816,6 +833,101 @@ export function friendlyProjectName(projectRoot) {
816
833
  return safeText(projectRoot, 4096).replace(/\/+$/, '').split('/').at(-1)?.slice(0, 120) || 'Local project';
817
834
  }
818
835
 
836
+ export function startDashboardInfo({ load = signal => request('/api/about', { signal }) } = {}) {
837
+ const $ = id => document.getElementById(id);
838
+ let closed = false, controller = null, timer = null, pending = null, command = '';
839
+ function render(info) {
840
+ if (!record(info) || typeof info.projectRoot !== 'string' ||
841
+ !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/.test(info.version ?? '')) {
842
+ throw new Error('invalid_dashboard_info');
843
+ }
844
+ $('project-path').textContent = safeText(info.projectRoot, 4096) || 'Path unavailable';
845
+ $('project-path').title = $('project-path').textContent;
846
+ $('graphlin-version').textContent = info.version;
847
+ $('version-update-steps').textContent = info.mode === 'demo'
848
+ ? 'Press Ctrl+C in the demo terminal, then run this command to restart the updated offline demo.'
849
+ : 'Press Ctrl+C in the viewer terminal, then run this command. Start a new Claude Code or Codex session after setup finishes.';
850
+ const branch = info.branch;
851
+ $('project-branch').textContent = branch?.status === 'branch'
852
+ ? safeText(branch.name, 1024) || 'Branch unavailable'
853
+ : branch?.status === 'detached' ? `Detached HEAD${branch.commit ? ` · ${safeText(branch.commit, 12)}` : ''}`
854
+ : branch?.status === 'not_git' ? 'Not a Git repository' : 'Branch unavailable';
855
+ $('project-branch').title = $('project-branch').textContent;
856
+ const latest = typeof info.update?.latest === 'string' &&
857
+ /^\d+\.\d+\.\d+$/.test(info.update.latest) ? info.update.latest : null;
858
+ const available = info.update?.status === 'available' && latest;
859
+ $('version-update-indicator').hidden = !available;
860
+ $('version-update-indicator').textContent = available ? `${latest} available` : 'Update available';
861
+ $('version-update-indicator').setAttribute('aria-label', available
862
+ ? `Graphlin ${latest} is available. View update instructions.` : 'View update instructions');
863
+ $('version-update-status').textContent = available ? `Graphlin ${latest} is available`
864
+ : info.update?.status === 'current' ? 'No newer release found'
865
+ : 'Update check unavailable';
866
+ const nextCommand = info.update?.command;
867
+ command = available && typeof nextCommand === 'string' && nextCommand.trim() &&
868
+ nextCommand.length <= 8192 && !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(nextCommand)
869
+ ? nextCommand : '';
870
+ $('version-update-guide').hidden = !command;
871
+ if ($('version-update-command').textContent !== command) {
872
+ $('version-update-command').textContent = command;
873
+ $('version-update-copy-status').textContent = '';
874
+ }
875
+ }
876
+ function refresh() {
877
+ if (closed) return Promise.resolve();
878
+ if (pending) return pending;
879
+ clearTimeout(timer);
880
+ controller = new AbortController();
881
+ pending = (async () => {
882
+ try {
883
+ await Promise.resolve();
884
+ if (closed) return;
885
+ const info = await load(controller.signal);
886
+ if (!closed) render(info);
887
+ } catch {
888
+ if (!closed) {
889
+ $('project-branch').textContent = 'Branch unavailable';
890
+ if ($('project-path').textContent === 'Checking…') $('project-path').textContent = 'Path unavailable';
891
+ if ($('graphlin-version').textContent === 'Checking…') $('graphlin-version').textContent = 'Version unavailable';
892
+ $('version-update-status').textContent = 'Update check unavailable';
893
+ $('version-update-indicator').hidden = true;
894
+ $('version-update-guide').hidden = true;
895
+ $('version-update-command').textContent = '';
896
+ command = '';
897
+ }
898
+ } finally {
899
+ pending = null;
900
+ controller = null;
901
+ if (!closed) timer = setTimeout(() => { void refresh(); }, 30000);
902
+ }
903
+ })();
904
+ return pending;
905
+ }
906
+ const onCopy = async () => {
907
+ if (closed || !command) return;
908
+ const copied = command;
909
+ try {
910
+ await window.navigator.clipboard.writeText(copied);
911
+ if (!closed && command === copied) $('version-update-copy-status').textContent = 'Copied';
912
+ } catch {
913
+ if (!closed && command === copied) {
914
+ $('version-update-copy-status').textContent = 'Select the command and copy it manually.';
915
+ $('version-update-command').focus();
916
+ }
917
+ }
918
+ };
919
+ $('version-update-copy').addEventListener('click', onCopy);
920
+ return {
921
+ refresh,
922
+ close() {
923
+ closed = true;
924
+ controller?.abort();
925
+ clearTimeout(timer);
926
+ $('version-update-copy').removeEventListener('click', onCopy);
927
+ },
928
+ };
929
+ }
930
+
819
931
  export const ORIENTATION_PROMPT = 'Orient yourself in this project: read its main files and explain how the components connect.';
820
932
 
821
933
  // Observations are not an installation or trust audit. Activity (including
@@ -1450,12 +1562,14 @@ export function startViewer() {
1450
1562
  connection: 'connecting', busy: false, exporting: false, epoch: 0, connectEpoch: 0,
1451
1563
  viewport: null, fitBounds: null, zoom: 1, followFit: true, lastGraphSignature: '', inspectorSignature: '',
1452
1564
  nodeElements: new Map(), edgeElements: new Map(), activityElements: new Map(),
1453
- views: new Map(), viewKey: null, view: null, displayGraph: null,
1454
- effects: new Map(), motionReady: false, movement: null, closed: false, projectName: '',
1565
+ views: new Map(), viewKey: null, view: null, displayGraph: null, searchQuery: '',
1566
+ nodeTypes: null, nodeTypeButtons: new Map(),
1567
+ effects: new Map(), liveReady: false, motionReady: false, movement: null, closed: false, projectName: '',
1455
1568
  };
1456
1569
  const motionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
1457
1570
  const sketches = createSketchCache();
1458
1571
  const detailSketches = createSketchCache(sketchDetails);
1572
+ const dashboardInfo = startDashboardInfo();
1459
1573
  const connectionDialog = startConnectionDialog({ onInfo: info => {
1460
1574
  state.projectName = friendlyProjectName(info.projectRoot);
1461
1575
  renderStatus();
@@ -1524,7 +1638,7 @@ export function startViewer() {
1524
1638
  $('onboarding-action').dataset.action = progress.next.action;
1525
1639
  // Preserve a manual text selection while live snapshots arrive.
1526
1640
  if ($('orientation-prompt').textContent !== ORIENTATION_PROMPT) $('orientation-prompt').textContent = ORIENTATION_PROMPT;
1527
- $('orientation').hidden = Boolean(state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
1641
+ $('orientation').hidden = Boolean(state.searchQuery || state.nodeTypes !== null || state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
1528
1642
  }
1529
1643
  function currentGraph() { return state.replayFrame?.graph || state.snapshot?.graph; }
1530
1644
  function applyTheme() {
@@ -1537,6 +1651,7 @@ export function startViewer() {
1537
1651
  if (state.viewKey !== key) {
1538
1652
  finishPan();
1539
1653
  clearMotion();
1654
+ state.liveReady = false;
1540
1655
  state.motionReady = false;
1541
1656
  let view = state.views.get(key);
1542
1657
  if (!view) view = createPresentation();
@@ -1580,14 +1695,14 @@ export function startViewer() {
1580
1695
  state.effects.set(id, effect);
1581
1696
  target.addEventListener('animationend', finish);
1582
1697
  }
1583
- function cancelMovement() {
1698
+ function cancelMovement({ fit = true } = {}) {
1584
1699
  const movement = state.movement;
1585
1700
  if (!movement) return;
1586
1701
  state.movement = null;
1587
1702
  window.cancelAnimationFrame?.(movement.frame);
1588
1703
  clearTimeout(movement.timer);
1589
1704
  if (state.displayGraph) paintGeometry(state.displayGraph);
1590
- if (movement.fitAfter) fitCamera(movement.fitAfter);
1705
+ if (fit && movement.fitAfter) fitCamera(movement.fitAfter);
1591
1706
  }
1592
1707
  function clearMotion() {
1593
1708
  cancelMovement();
@@ -1595,10 +1710,11 @@ export function startViewer() {
1595
1710
  }
1596
1711
  function resetMotionBaseline() {
1597
1712
  finishPan();
1713
+ state.liveReady = false;
1598
1714
  state.motionReady = false;
1599
1715
  clearMotion();
1600
1716
  }
1601
- function animateChanges(changes, before) {
1717
+ function animateChanges(changes, before, focusNodeId) {
1602
1718
  // Re-addition always cancels a removal, even when motion is suppressed.
1603
1719
  for (const node of currentGraph().nodes) if (state.effects.get(node.id)?.element) finishEffect(node.id);
1604
1720
  if (!motionAllowed()) return;
@@ -1627,7 +1743,7 @@ export function startViewer() {
1627
1743
  removals.push(decoration);
1628
1744
  }
1629
1745
  if (removals.length) {
1630
- fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
1746
+ if (!focusNodeId) fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
1631
1747
  $('empty-canvas').hidden = true;
1632
1748
  $('effects-layer').append(...removals);
1633
1749
  }
@@ -1677,15 +1793,25 @@ export function startViewer() {
1677
1793
  const eligible = streamed && state.motionReady && !switched && !state.replayFrame &&
1678
1794
  snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay' && motionAllowed();
1679
1795
  const changes = liveNodeChanges(state.snapshot?.graph, snapshot.graph, eligible);
1796
+ // A live baseline is independent of animation preferences. Reconnects and
1797
+ // initial/session snapshots establish it without focusing an old arrival.
1798
+ const live = streamed && state.liveReady && !switched && !state.replayFrame &&
1799
+ snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay';
1800
+ const visible = new Set(filterDiagram(snapshot.graph, state.searchQuery, state.nodeTypes).nodes.map(node => node.id));
1801
+ const focusNodeId = liveNodeChanges(state.snapshot?.graph, snapshot.graph, live).added.filter(id => visible.has(id)).at(-1);
1680
1802
  const before = state.displayGraph;
1681
- cancelMovement();
1682
- if (switched) resetView();
1803
+ cancelMovement({ fit: !focusNodeId });
1804
+ if (switched) {
1805
+ resetView();
1806
+ state.nodeTypes = null;
1807
+ }
1683
1808
  state.snapshot = snapshot;
1684
1809
  state.epoch += 1;
1685
1810
  state.frames = historyFrames(snapshot);
1686
1811
  state.replayFrame = reconcileReplayFrame(state.frames, state.replayFrame);
1687
- render();
1688
- animateChanges(changes, before);
1812
+ render({ focusNodeId });
1813
+ animateChanges(changes, before, focusNodeId);
1814
+ state.liveReady = streamed && !state.replayFrame && snapshot.mode !== 'replay';
1689
1815
  state.motionReady = streamed && !state.replayFrame && snapshot.mode !== 'replay' && motionAllowed();
1690
1816
  $('updated-at').textContent = `Snapshot received ${formatTime(Date.now())}`;
1691
1817
  }
@@ -1812,14 +1938,18 @@ export function startViewer() {
1812
1938
  }
1813
1939
  }
1814
1940
  function revealInspector() {
1941
+ revealDetailsSection($('inspector-body')?.parentElement);
1942
+ }
1943
+ function revealDetailsSection(panel) {
1944
+ setWorkspacePanel('details', true);
1815
1945
  const container = $('live-sidebar');
1816
- const panel = $('inspector-body')?.parentElement;
1817
1946
  if (!container?.scrollTo || !container.getBoundingClientRect || !panel?.getBoundingClientRect ||
1818
1947
  !container.contains(panel)) return;
1819
1948
  const region = container.getBoundingClientRect();
1820
1949
  const evidence = panel.getBoundingClientRect();
1821
1950
  if (!Number.isFinite(region.top) || !Number.isFinite(evidence.top) || !(region.height > 0)) return;
1822
- const offset = evidence.top - region.top - (container.clientTop || 0);
1951
+ const headingHeight = $('details-heading')?.getBoundingClientRect?.().height || 0;
1952
+ const offset = evidence.top - region.top - (container.clientTop || 0) - headingHeight;
1823
1953
  if (Math.abs(offset) < 1) return;
1824
1954
  container.scrollTo({
1825
1955
  top: Math.max(0, (container.scrollTop || 0) + offset),
@@ -1869,6 +1999,23 @@ export function startViewer() {
1869
1999
  cancelMovement();
1870
2000
  fitCamera(graphBounds(graph));
1871
2001
  }
2002
+ function focusNode(node, bounds) {
2003
+ finishPan();
2004
+ const zoom = Math.max(.5, state.zoom);
2005
+ const scale = state.zoom / zoom;
2006
+ const width = state.viewport.width * scale;
2007
+ const height = state.viewport.height * scale;
2008
+ state.viewport = {
2009
+ x: node.x + NODE_WIDTH / 2 - width / 2,
2010
+ y: node.y + NODE_HEIGHT / 2 - height / 2,
2011
+ width, height,
2012
+ };
2013
+ state.zoom = zoom;
2014
+ state.fitBounds = bounds;
2015
+ // Removal cleanup must not replace arrival focus with a later fit-all.
2016
+ state.followFit = false;
2017
+ setViewBox();
2018
+ }
1872
2019
  function zoom(factor, anchor = { x: .5, y: .5 }) {
1873
2020
  if (!state.viewport || !state.fitBounds) return;
1874
2021
  cancelMovement();
@@ -1885,20 +2032,49 @@ export function startViewer() {
1885
2032
  state.followFit = false;
1886
2033
  setViewBox();
1887
2034
  }
1888
- function renderGraph({ forceFit = false } = {}) {
2035
+ function renderNodeTypeFilters(canonical) {
2036
+ const container = $('node-type-filters');
2037
+ if (!container) return;
2038
+ // Discover kinds from the current canonical canvas, never search results.
2039
+ const kinds = [...new Set(canonical.nodes.map(node => node.kind))].sort();
2040
+ for (const [kind, button] of state.nodeTypeButtons) {
2041
+ if (!kinds.includes(kind)) {
2042
+ button.remove();
2043
+ state.nodeTypeButtons.delete(kind);
2044
+ }
2045
+ }
2046
+ kinds.forEach((kind, index) => {
2047
+ let button = state.nodeTypeButtons.get(kind);
2048
+ if (!button) {
2049
+ button = html('button', upperFirst(kind), 'node-type-filter');
2050
+ button.setAttribute('type', 'button');
2051
+ button.dataset.kind = kind;
2052
+ state.nodeTypeButtons.set(kind, button);
2053
+ }
2054
+ button.setAttribute('aria-pressed', String(state.nodeTypes === null || state.nodeTypes.has(kind)));
2055
+ if (container.children[index] !== button) container.insertBefore(button, container.children[index] || null);
2056
+ });
2057
+ $('node-types-all')?.setAttribute('aria-pressed', String(state.nodeTypes === null));
2058
+ $('node-types-none')?.setAttribute('aria-pressed', String(state.nodeTypes?.size === 0));
2059
+ }
2060
+ function renderGraph({ forceFit = false, arrange = false, focusNodeId } = {}) {
1889
2061
  const canonical = currentGraph();
1890
2062
  if (!canonical) return;
1891
2063
  const view = presentation();
1892
2064
  applyTheme();
1893
- const graph = projectPresentation(canonical, view);
2065
+ renderNodeTypeFilters(canonical);
2066
+ // Lay out only visible nodes so hidden components leave no empty slots.
2067
+ // Evidence, exports and live arrival detection retain the canonical graph.
2068
+ const graph = projectPresentation(filterDiagram(canonical, state.searchQuery, state.nodeTypes), view, { arrange });
1894
2069
  state.displayGraph = graph;
1895
2070
  const routes = graphEdgeRoutes(graph);
1896
2071
  const bounds = graphBounds(graph, routes);
1897
2072
  const signature = cameraGraphSignature(graph, view.algorithm);
1898
- // Set the physical viewport before inserting newcomers or starting motion.
1899
- // A manual camera survives status updates, but the next diagram change fits
1900
- // the complete map, even with automatic arrangement disabled.
1901
- if (forceFit || !state.viewport || signature !== state.lastGraphSignature) fitCamera(bounds);
2073
+ // Focus the final projected position before inserting newcomers. Other
2074
+ // diagram changes retain fit-all; metadata-only updates keep the camera.
2075
+ const newest = graph.nodes.find(node => node.id === focusNodeId);
2076
+ if (newest && state.viewport && !forceFit) focusNode(newest, bounds);
2077
+ else if (forceFit || !state.viewport || signature !== state.lastGraphSignature) fitCamera(bounds);
1902
2078
  state.lastGraphSignature = signature;
1903
2079
  $('layout').value = view.algorithm;
1904
2080
  $('auto-arrange').checked = view.auto;
@@ -1966,6 +2142,7 @@ export function startViewer() {
1966
2142
  center,
1967
2143
  svgElement('rect', { class: 'selection-ring', x: -7, y: -7, width: NODE_WIDTH + 14, height: NODE_HEIGHT + 14, rx: 14 }),
1968
2144
  );
2145
+ group.setAttribute('transform', `translate(${node.x} ${node.y})`);
1969
2146
  state.nodeElements.set(node.id, group);
1970
2147
  $('node-layer').append(group);
1971
2148
  }
@@ -2009,6 +2186,9 @@ export function startViewer() {
2009
2186
  $('diagram-title').textContent = `${state.replayFrame ? 'Historical' : 'Live'} architecture, revision ${graph.revision}`;
2010
2187
  $('diagram-desc').textContent = `${graph.nodes.length} components and ${graph.edges.length} relationships. Code interpretation does not establish runtime connectivity. Use Tab and Enter to inspect a component or relationship. With the diagram focused, use plus and minus to zoom, arrow keys to pan, and 0 to fit.`;
2011
2188
  $('graph-count').textContent = `${graph.nodes.length} components · ${graph.edges.length} relationships`;
2189
+ $('diagram-search-status').textContent = state.searchQuery || state.nodeTypes !== null
2190
+ ? `${graph.nodes.length} of ${canonical.nodes.length} components shown` : '';
2191
+ $('diagram-search-clear').hidden = !state.searchQuery;
2012
2192
  $('empty-canvas').hidden = graph.nodes.length > 0 || removalBounds().length > 0;
2013
2193
  const classifier = state.snapshot.paused ? 'paused' : state.snapshot.status.classifier;
2014
2194
  const emptyMessages = {
@@ -2018,7 +2198,13 @@ export function startViewer() {
2018
2198
  unavailable: ['Waiting for classification.', 'The classifier is unavailable. Safe activity continues below; supported architecture will appear when classification recovers.'],
2019
2199
  timeout: ['Evidence needs another moment.', 'Classification exceeded its deadline. Activity still appears below, and no unsupported components are added.'],
2020
2200
  };
2021
- const message = state.replayFrame
2201
+ const message = state.nodeTypes?.size === 0
2202
+ ? ['No component types selected.', 'Choose a type or All types to show components.']
2203
+ : state.searchQuery
2204
+ ? ['No matching components.', `No selected components match “${state.searchQuery}”. Try another search or press Esc to clear the search.`]
2205
+ : state.nodeTypes !== null && canonical.nodes.length
2206
+ ? ['No matching components.', 'Choose another type or All types to show components.']
2207
+ : state.replayFrame
2022
2208
  ? ['No components in this revision.', 'Move through the recent revisions or return to Live to follow the current map.']
2023
2209
  : emptyMessages[classifier] || ['Your architecture starts here.', 'Work in a connected agent session. Components appear when approved evidence supports them; activity can arrive first.'];
2024
2210
  $('empty-title').textContent = message[0];
@@ -2087,8 +2273,7 @@ export function startViewer() {
2087
2273
  finishPan();
2088
2274
  cancelMovement();
2089
2275
  const before = state.displayGraph;
2090
- projectPresentation(graph, presentation(), { arrange: true });
2091
- renderGraph({ forceFit: true });
2276
+ renderGraph({ forceFit: true, arrange: true });
2092
2277
  moveLayout(before, state.displayGraph);
2093
2278
  announce(`Arranged using ${LAYOUT_NAMES[state.view.algorithm]}. Evidence and selection are unchanged.`);
2094
2279
  }
@@ -2231,6 +2416,7 @@ export function startViewer() {
2231
2416
  button.addEventListener('click', () => {
2232
2417
  const row = state.activityElements.get(event.id);
2233
2418
  if (row) {
2419
+ setWorkspacePanel('activity', true);
2234
2420
  row.querySelector('button').focus();
2235
2421
  row.scrollIntoView({ block: 'nearest' });
2236
2422
  }
@@ -2293,6 +2479,7 @@ export function startViewer() {
2293
2479
  updateSelection();
2294
2480
  renderInspector();
2295
2481
  renderActivity();
2482
+ revealInspector();
2296
2483
  announce(`Related evidence for ${(node || edge).label} is shown in the inspector.`);
2297
2484
  } else toast(state.replayFrame ? 'No evidence link for this event in the displayed revision. Return to Live to inspect current links.' : 'This captured event has no architecture evidence link. Tool activity alone does not establish an architectural claim.');
2298
2485
  });
@@ -2305,10 +2492,10 @@ export function startViewer() {
2305
2492
  $('activity-empty').hidden = events.length > 0;
2306
2493
  $('activity-list').hidden = events.length === 0;
2307
2494
  }
2308
- function render() {
2495
+ function render(graphOptions) {
2309
2496
  renderStatus();
2310
2497
  renderOnboarding();
2311
- renderGraph();
2498
+ renderGraph(graphOptions);
2312
2499
  renderInspector();
2313
2500
  renderHistory();
2314
2501
  renderActivity();
@@ -2323,6 +2510,7 @@ export function startViewer() {
2323
2510
  function replayAt(index) {
2324
2511
  const frame = state.frames[index];
2325
2512
  if (!frame) return;
2513
+ setWorkspacePanel('history', true);
2326
2514
  resetMotionBaseline();
2327
2515
  state.replayFrame = frame;
2328
2516
  render();
@@ -2384,6 +2572,7 @@ export function startViewer() {
2384
2572
  connection('reconnecting');
2385
2573
  error('The live connection was lost. Displaying the last received snapshot while the viewer reconnects.');
2386
2574
  });
2575
+ void dashboardInfo.refresh();
2387
2576
  // Optional authenticated metadata must not hold up the event stream or
2388
2577
  // turn an older server's missing endpoint into a connection failure.
2389
2578
  const controller = new AbortController();
@@ -2392,6 +2581,8 @@ export function startViewer() {
2392
2581
  const info = normalizeConnectionInfo(await request('/api/connection-info', { signal: controller.signal }));
2393
2582
  if (!state.closed && attempt === state.connectEpoch) {
2394
2583
  state.projectName = friendlyProjectName(info.projectRoot);
2584
+ $('project-path').textContent = info.projectRoot;
2585
+ $('project-path').title = info.projectRoot;
2395
2586
  renderStatus();
2396
2587
  }
2397
2588
  } catch { /* The project ID remains a usable fallback. */ }
@@ -2446,15 +2637,110 @@ export function startViewer() {
2446
2637
  $('orientation-prompt').focus();
2447
2638
  }
2448
2639
  };
2449
- const onToggleActivity = () => {
2450
- const hidden = !$('activity-content').hidden;
2451
- $('activity-content').hidden = hidden;
2452
- $('activity-toggle').textContent = hidden ? 'Show activity log' : 'Hide activity log';
2453
- $('activity-toggle').setAttribute('aria-expanded', String(!hidden));
2640
+ const workspacePanels = {
2641
+ details: { panel: $('live-sidebar'), toggle: $('details-toggle') },
2642
+ history: { panel: $('history-panel'), toggle: $('history-toggle') },
2643
+ activity: { panel: $('activity-panel'), toggle: $('activity-toggle') },
2644
+ };
2645
+ function setWorkspacePanel(name, open) {
2646
+ const { panel, toggle } = workspacePanels[name];
2647
+ if (!open && panel.contains(document.activeElement)) toggle.focus();
2648
+ panel.hidden = !open;
2649
+ toggle.setAttribute('aria-expanded', String(open));
2650
+ if (name === 'details') $('workspace-body').dataset.detailsOpen = String(open);
2651
+ if (name === 'activity') $('activity-content').hidden = !open;
2652
+ }
2653
+ const onToggleDetails = () => setWorkspacePanel('details', $('live-sidebar').hidden);
2654
+ const onToggleHistory = () => setWorkspacePanel('history', $('history-panel').hidden);
2655
+ const onToggleActivity = () => setWorkspacePanel('activity', $('activity-panel').hidden);
2656
+ const onCloseDetails = () => {
2657
+ setWorkspacePanel('details', false);
2658
+ $('details-toggle').focus();
2659
+ };
2660
+ const onViewUpdate = () => {
2661
+ $('version-update-guide').open = !$('version-update-guide').hidden;
2662
+ revealDetailsSection($('version-update-status'));
2454
2663
  };
2664
+ const panelKeyHandlers = new Map();
2665
+ for (const [name, { panel, toggle }] of Object.entries(workspacePanels)) {
2666
+ setWorkspacePanel(name, false);
2667
+ const onKeyDown = event => {
2668
+ if (event.key !== 'Escape' || event.defaultPrevented || event.target?.tagName?.toLowerCase() === 'select') return;
2669
+ event.preventDefault();
2670
+ setWorkspacePanel(name, false);
2671
+ toggle.focus();
2672
+ };
2673
+ panel.addEventListener('keydown', onKeyDown);
2674
+ panelKeyHandlers.set(panel, onKeyDown);
2675
+ }
2455
2676
  $('onboarding-action').addEventListener('click', onOnboardingAction);
2456
2677
  $('orientation-copy').addEventListener('click', onCopyOrientation);
2678
+ $('details-toggle').addEventListener('click', onToggleDetails);
2679
+ $('details-close').addEventListener('click', onCloseDetails);
2680
+ $('version-update-indicator').addEventListener('click', onViewUpdate);
2681
+ $('history-toggle').addEventListener('click', onToggleHistory);
2457
2682
  $('activity-toggle').addEventListener('click', onToggleActivity);
2683
+ const applyFilters = () => {
2684
+ // Filtering is not a source change: cancel existing decoration/movement
2685
+ // and render directly, without changing the snapshot arrival baseline.
2686
+ finishPan();
2687
+ clearMotion();
2688
+ renderOnboarding();
2689
+ renderGraph({ forceFit: true, arrange: true });
2690
+ };
2691
+ const onSearchInput = () => {
2692
+ if (state.closed || state.searchQuery === $('diagram-search').value) return;
2693
+ state.searchQuery = $('diagram-search').value;
2694
+ applyFilters();
2695
+ };
2696
+ const onNodeTypeClick = event => {
2697
+ if (state.closed || !currentGraph()) return;
2698
+ const container = $('node-type-filters');
2699
+ let button = event.target;
2700
+ while (button && button.parentElement !== container) button = button.parentElement;
2701
+ const kind = button?.dataset.kind;
2702
+ if (!kind || !state.nodeTypeButtons.has(kind)) return;
2703
+ // null means all, including future kinds. Explicit selections retain their
2704
+ // choices when a kind disappears and returns; new kinds stay unselected.
2705
+ if (state.nodeTypes === null) state.nodeTypes = new Set(currentGraph().nodes.map(node => node.kind));
2706
+ if (state.nodeTypes.has(kind)) state.nodeTypes.delete(kind);
2707
+ else state.nodeTypes.add(kind);
2708
+ applyFilters();
2709
+ };
2710
+ const onAllNodeTypes = () => {
2711
+ if (state.closed) return;
2712
+ state.nodeTypes = null;
2713
+ applyFilters();
2714
+ };
2715
+ const onNoNodeTypes = () => {
2716
+ if (state.closed) return;
2717
+ state.nodeTypes = new Set();
2718
+ applyFilters();
2719
+ };
2720
+ const clearSearch = () => {
2721
+ $('diagram-search').value = '';
2722
+ onSearchInput();
2723
+ $('diagram-search').focus({ preventScroll: true });
2724
+ };
2725
+ const onSearchKeyDown = event => {
2726
+ if (state.closed || event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey ||
2727
+ $('diagnostics-dialog').open || $('connection-dialog').open || document.querySelector?.('dialog[open], [role="dialog"][aria-modal="true"]')) return;
2728
+ const target = event.target || document.activeElement;
2729
+ if (target !== $('diagram-search') && isSearchTypingTarget(target)) return;
2730
+ if (event.key === '/' && target !== $('diagram-search')) {
2731
+ event.preventDefault();
2732
+ $('diagram-search').focus({ preventScroll: true });
2733
+ } else if (event.key === 'Escape' && state.searchQuery) {
2734
+ event.preventDefault();
2735
+ clearSearch();
2736
+ }
2737
+ };
2738
+ $('diagram-search').addEventListener('input', onSearchInput);
2739
+ $('diagram-search-clear').addEventListener('click', clearSearch);
2740
+ $('node-type-filters')?.addEventListener('click', onNodeTypeClick);
2741
+ $('node-types-all')?.addEventListener('click', onAllNodeTypes);
2742
+ $('node-types-none')?.addEventListener('click', onNoNodeTypes);
2743
+ window.addEventListener('keydown', onSearchKeyDown);
2458
2744
  $('pause').addEventListener('click', () => control(state.snapshot?.paused ? 'resume' : 'pause'));
2459
2745
  $('session').addEventListener('change', () => control('session', $('session').value));
2460
2746
  $('export').addEventListener('click', exportJSON);
@@ -2595,9 +2881,21 @@ export function startViewer() {
2595
2881
  state.closed = true;
2596
2882
  projectController?.abort();
2597
2883
  projectController = null;
2884
+ dashboardInfo.close();
2598
2885
  $('onboarding-action').removeEventListener('click', onOnboardingAction);
2599
2886
  $('orientation-copy').removeEventListener('click', onCopyOrientation);
2887
+ $('details-toggle').removeEventListener('click', onToggleDetails);
2888
+ $('details-close').removeEventListener('click', onCloseDetails);
2889
+ $('version-update-indicator').removeEventListener('click', onViewUpdate);
2890
+ $('history-toggle').removeEventListener('click', onToggleHistory);
2600
2891
  $('activity-toggle').removeEventListener('click', onToggleActivity);
2892
+ for (const [panel, handler] of panelKeyHandlers) panel.removeEventListener('keydown', handler);
2893
+ $('diagram-search').removeEventListener('input', onSearchInput);
2894
+ $('diagram-search-clear').removeEventListener('click', clearSearch);
2895
+ $('node-type-filters')?.removeEventListener('click', onNodeTypeClick);
2896
+ $('node-types-all')?.removeEventListener('click', onAllNodeTypes);
2897
+ $('node-types-none')?.removeEventListener('click', onNoNodeTypes);
2898
+ window.removeEventListener?.('keydown', onSearchKeyDown);
2601
2899
  $('architecture').removeEventListener('wheel', onDiagramWheel);
2602
2900
  connectionDialog.dispose();
2603
2901
  diagnosticsDialog.dispose();