diff --git a/.codebase-memory.json b/.codebase-memory.json
new file mode 100644
index 000000000..7289ac032
--- /dev/null
+++ b/.codebase-memory.json
@@ -0,0 +1,95 @@
+{
+ "test_impact": {
+ "version": 1,
+ "tests": {
+ "presets": {
+ "c-cbm": true
+ }
+ },
+ "rules": [
+ {
+ "id": "harness",
+ "paths": ["tests/test_framework.h", "tests/test_helpers.*", "tests/test_main.c"],
+ "action": "run_all"
+ },
+ {
+ "id": "engine",
+ "paths": [
+ ".codebase-memory.json",
+ "src/mcp/test_impact*",
+ "src/store/store_impact.*",
+ "scripts/test-impact/**",
+ "scripts/ci/test-impact-*"
+ ],
+ "action": "run_all"
+ },
+ {
+ "id": "build",
+ "paths": [
+ "scripts/build.sh",
+ "scripts/env.sh",
+ "scripts/test.sh",
+ "scripts/run-tests-parallel.sh",
+ "scripts/run-test-wave.py",
+ "scripts/path-safety.sh",
+ "scripts/clean.sh",
+ "scripts/suite-failure-report.sh",
+ "scripts/ci/verify-shard-union.sh",
+ "scripts/embed-frontend.sh",
+ "/.gitattributes"
+ ],
+ "action": "run_all"
+ },
+ {
+ "id": "grammars",
+ "paths": ["internal/cbm/vendored/**"],
+ "action": "run_all"
+ },
+ {
+ "id": "fixtures",
+ "paths": ["tests/fixtures/*/**"],
+ "action": "referencing_tests",
+ "key": "path_segment:2"
+ },
+ {
+ "id": "contracts",
+ "paths": ["tests/**/*.{sh,py,json,txt,ps1}"],
+ "action": "ignore"
+ },
+ {
+ "id": "notices",
+ "paths": ["NOTICE*", "CHANGELOG*", "THIRD_PARTY*"],
+ "action": "ignore"
+ },
+ {
+ "id": "repo-data",
+ "paths": [
+ "scripts/**",
+ "pkg/**",
+ "test-infrastructure/**",
+ "docs/**",
+ "**/*.md",
+ "graph-ui/**",
+ "tools/**",
+ "tests/fuzz/corpus/**",
+ "/{.clang-format,.clang-tidy,.cppcheck,.gitignore,.gitleaksignore,DCO,Formula}",
+ "/{flake.lock,flake.nix,glama.json,install.ps1,install.sh,server.json}"
+ ],
+ "action": "referencing_tests",
+ "key": "path",
+ "unreferenced": "ignore"
+ }
+ ],
+ "unmapped_files": "run_all",
+ "coverage": {
+ "compatibility_paths": [
+ "tests/test_framework.h",
+ "tests/test_main.c",
+ "tests/test_helpers.h",
+ "Makefile.cbm",
+ "scripts/test-impact/coverage-map.py",
+ "scripts/test-impact/coverage-merge.py"
+ ]
+ }
+ }
+}
diff --git a/.gitattributes b/.gitattributes
index 1b2d4fb75..d474291f3 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -15,3 +15,10 @@ scripts/hooks/pre-commit text eol=lf
# Chialisp grammar corpus: one case asserts CRLF comment termination, so this
# file must keep its exact bytes on every platform (no eol conversion). (#919)
tools/tree-sitter-chialisp/test/corpus/comments.txt -text
+
+# llvm-profdata text captures: tests/test_profile_text_parser.py checks their
+# SHA-256, so they keep their exact bytes on every platform (no eol conversion).
+tests/fixtures/profile_text/*.proftext -text
+# Golden profile-map outputs: tests/test_profile_map_v2.py compares the
+# producer's LF bytes against them, so they keep their exact bytes too.
+tests/fixtures/profile_map_v2/*.tsv -text
diff --git a/.github/scancode-constraints.txt b/.github/scancode-constraints.txt
new file mode 100644
index 000000000..3b331d2f4
--- /dev/null
+++ b/.github/scancode-constraints.txt
@@ -0,0 +1,5 @@
+# Constraints for the ScanCode install of the license gate (PIP_CONSTRAINT).
+# pluggy 1.7.0 (2026-10-15) no longer finds plugincode 32.0.0's class-level
+# hookspecs: scancode then crashes at import with "did not find any
+# 'pre_scan' hooks" and the gate fails on every run.
+pluggy==1.6.0
diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml
index 367c1dd58..6fcda15e4 100644
--- a/.github/workflows/_lint.yml
+++ b/.github/workflows/_lint.yml
@@ -3,13 +3,19 @@
name: Lint & Security
on:
- workflow_call: {}
+ workflow_call:
+ inputs:
+ lanes:
+ description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)'
+ type: string
+ default: all
permissions:
contents: read
jobs:
lint:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint') }}
# Pinned, not ubuntu-latest: the LLVM install below adds an
# apt.llvm.org llvm-toolchain-NOBLE repo, which is 24.04 by name. When
# GitHub moves ubuntu-latest to the next LTS this job breaks on a day
@@ -17,8 +23,11 @@ jobs:
runs-on: ubuntu-24.04
# 15 min proved too tight on a slow runner day and a timed-out lint reads
# as "cancelled", which the release graph must treat as a hard stop; keep
- # a bound, but one only a genuine hang can hit (normal runtime ~5 min).
- timeout-minutes: 30
+ # a bound, but one only a genuine hang can hit. 30 min stopped being that
+ # in 2026-10: single-process cppcheck over a src tree that grew ~60 % in
+ # files took the lint step from ~8 to 22-29 min, and three runs on
+ # 2026-10-13 were cancelled at 30 min with cppcheck still alive.
+ timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -64,6 +73,7 @@ jobs:
# provability (never suppressed — the NOLINT ban applies here too).
# Vendored-tree diagnostics are path-filtered, mirroring .cppcheck.
lint-mem:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint-mem') }}
# Pinned for the same reason as `lint`: llvm-toolchain-noble-21 below.
runs-on: ubuntu-24.04
timeout-minutes: 60
diff --git a/.github/workflows/_security.yml b/.github/workflows/_security.yml
index ce044eed2..666573471 100644
--- a/.github/workflows/_security.yml
+++ b/.github/workflows/_security.yml
@@ -4,7 +4,12 @@
name: Security Gate
on:
- workflow_call: {}
+ workflow_call:
+ inputs:
+ lanes:
+ description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)'
+ type: string
+ default: all
permissions:
contents: read
@@ -16,19 +21,28 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Layer 1: Static allow-list audit"
- run: scripts/security-audit.sh
+ run: |
+ bash tests/test_security_audit_subprocess_calls.sh
+ scripts/security-audit.sh
- name: "Layer 6: UI security audit"
- run: scripts/security-ui.sh
+ run: |
+ bash tests/test_security_ui_urls.sh
+ scripts/security-ui.sh
- name: "Layer 8: Vendored dependency integrity"
run: scripts/security-vendored.sh
license-gate:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'license-gate') }}
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ScanCode Toolkit
- run: pipx install scancode-toolkit
+ # Pinned: an unpinned install picked up pluggy 1.7.0, which breaks
+ # scancode at import (see .github/scancode-constraints.txt).
+ env:
+ PIP_CONSTRAINT: ${{ github.workspace }}/.github/scancode-constraints.txt
+ run: pipx install scancode-toolkit==32.5.0
- name: "Gate self-test (a planted violation must be detected)"
run: scripts/license-gate.sh --selftest
- name: "License compliance gate (one finding fails)"
@@ -39,6 +53,7 @@ jobs:
run: scripts/audit-license-provenance.py
codeql-gate:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }}
runs-on: ubuntu-latest
timeout-minutes: 240
# Without security-events:read this job inherits the workflow's
diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml
index 1dd85e1be..1a4d4eb57 100644
--- a/.github/workflows/_smoke.yml
+++ b/.github/workflows/_smoke.yml
@@ -95,6 +95,13 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper; the
+ # macOS runner images have none on PATH.
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: pkg/go/go.mod
+ cache: false
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
@@ -118,6 +125,7 @@ jobs:
env:
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
SMOKE_REQUIRE_UI: "1"
+ SMOKE_REQUIRE_WRAPPERS: "1"
- name: Security audits
run: |
@@ -175,6 +183,13 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper; the
+ # MSYS2 shell below inherits it (path-type: inherit).
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: pkg/go/go.mod
+ cache: false
+
- uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
with:
msystem: ${{ matrix.arch == 'arm64' && 'CLANGARM64' || 'CLANG64' }}
@@ -224,6 +239,7 @@ jobs:
env:
SMOKE_ARCH: ${{ matrix.arch }}
SMOKE_REQUIRE_UI: "1"
+ SMOKE_REQUIRE_WRAPPERS: "1"
run: |
export CBM_SMOKE_ARTIFACT_DIR="$(cygpath -u "$RUNNER_TEMP")/cbm-artifact"
bash test-infrastructure/vm/vm-smoke.sh
@@ -279,6 +295,12 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper.
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: pkg/go/go.mod
+ cache: false
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries-linux-${{ matrix.arch }}-portable
@@ -301,6 +323,7 @@ jobs:
env:
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
SMOKE_REQUIRE_UI: "1"
+ SMOKE_REQUIRE_WRAPPERS: "1"
# The -portable binary is what all linux install/update paths now deliver;
# it MUST start on old glibc (Debian 11 / RHEL 8 / Ubuntu 20.04). Runs it
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index 61ab9d0e0..39baa516c 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -16,6 +16,18 @@ on:
description: 'Split each C-suite leg across parallel shard jobs (ubuntu x3, windows x2); off = one job per leg, byte-identical to the pre-shard topology'
type: boolean
default: false
+ shard_profile:
+ description: 'Shard counts when shard_suites is on: standard = ubuntu x3, windows x2 (dry run, release); pr = ubuntu x86 x1, ubuntu arm64 x2, windows x1 (fewer jobs re-paying setup)'
+ type: string
+ default: standard
+ lanes:
+ description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list): only those legs and jobs run; "all" = every one (dry run, release)'
+ type: string
+ default: all
+ test_selection:
+ description: 'PR test selection (smart CI): the artifact the select-tests job uploaded; the Linux x86_64 test-unix leg runs only its tests, the arm64, macOS and Windows legs run every test. Empty = every test (dry run, release, and any PR the selection does not narrow)'
+ type: string
+ default: ''
permissions:
contents: read
@@ -30,18 +42,26 @@ jobs:
outputs:
unix: ${{ steps.set.outputs.unix }}
windows: ${{ steps.set.outputs.windows }}
+ tsan: ${{ steps.set.outputs.tsan }}
+ has_unix: ${{ steps.set.outputs.has_unix }}
+ has_windows: ${{ steps.set.outputs.has_windows }}
+ has_tsan: ${{ steps.set.outputs.has_tsan }}
steps:
- name: Compute matrices
id: set
env:
BROAD: ${{ inputs.broad_platforms }}
SHARDS: ${{ inputs.shard_suites }}
+ SHARD_PROFILE: ${{ inputs.shard_profile }}
+ LANES: ${{ inputs.lanes }}
run: |
+ # Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh)
+ # it serves; the tag is what lane selection filters on below.
CORE_UNIX='[
- {"os":"ubuntu-latest","cc":"gcc","cxx":"g++"},
- {"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++"},
- {"os":"macos-14","cc":"cc","cxx":"c++"},
- {"os":"macos-15-intel","cc":"cc","cxx":"c++"}
+ {"os":"ubuntu-latest","cc":"gcc","cxx":"g++","lane":"unix-x86"},
+ {"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++","lane":"unix-arm64"},
+ {"os":"macos-14","cc":"cc","cxx":"c++","lane":"unix-macos14"},
+ {"os":"macos-15-intel","cc":"cc","cxx":"c++","lane":"unix-macos-intel"}
]'
# Broad matrix legs are REQUIRED gates (no optional/continue-on-error
# escape hatches): the release dry run must be all-green, every platform.
@@ -59,7 +79,7 @@ jobs:
# interception crashed (interception_win: unhandled instruction). With
# the native ARM64 toolchain ASan instruments native ARM64 code, so it
# is a real (non-optional) gate, not a tolerated emulated-flake.
- CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64"}]'
+ CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64","lane":"windows"}]'
BROAD_WIN='[{"os":"windows-2025","msystem":"CLANG64","pkg":"x86_64"},{"os":"windows-11-arm","msystem":"CLANGARM64","pkg":"aarch64"}]'
if [ "$BROAD" = "true" ]; then
UNIX=$(jq -cn --argjson a "$CORE_UNIX" --argjson b "$BROAD_UNIX" '$a + $b')
@@ -68,6 +88,30 @@ jobs:
UNIX=$(jq -cn --argjson a "$CORE_UNIX" '$a')
WIN=$(jq -cn --argjson a "$CORE_WIN" '$a')
fi
+ # The ThreadSanitizer legs (test-tsan), tagged the same way.
+ TSAN='[
+ {"os":"ubuntu-latest","cc":"clang","cxx":"clang++","lane":"tsan-x86"},
+ {"os":"ubuntu-24.04-arm","cc":"clang","cxx":"clang++","lane":"tsan-arm"},
+ {"os":"macos-14","cc":"cc","cxx":"c++","lane":"tsan-mac"}
+ ]'
+ # Lane selection. "all" (dry run, release) keeps every leg and drops
+ # the tags, so those matrices -- and their job names -- stay exactly
+ # as before lane selection. A PR keeps only its selected legs, and
+ # the tag stays in the job name, where ci-ok reads it back. An
+ # untagged leg could never be selected: that is a configuration
+ # error here, never a leg silently dropped.
+ if [ "$LANES" = "all" ]; then
+ UNIX=$(jq -c 'map(del(.lane))' <<< "$UNIX")
+ WIN=$(jq -c 'map(del(.lane))' <<< "$WIN")
+ TSAN=$(jq -c 'map(del(.lane))' <<< "$TSAN")
+ else
+ for M in "$UNIX" "$WIN" "$TSAN"; do
+ jq -e 'all(.[]; has("lane"))' <<< "$M" > /dev/null || { echo "::error::untagged test leg in $M"; exit 1; }
+ done
+ UNIX=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$UNIX")
+ WIN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$WIN")
+ TSAN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$TSAN")
+ fi
# Suite sharding (opt-in via the shard_suites input): each platform's
# C-suite run is split across N runner jobs. CBM_TEST_SHARD="i/N"
# slices deterministically inside run-tests-parallel.sh; each
@@ -78,20 +122,37 @@ jobs:
# shards); Windows gets 2 (every extra shard re-pays ~5 min of
# MSYS2 setup); macOS stays at 1 — mac legs are not the critical
# path and mac runner concurrency ceilings are the tightest.
+ # The pr profile shards less: a PR runs few legs and every shard
+ # re-pays its setup, so x86 and Windows run whole and only arm64,
+ # the slowest leg (~72 min unsharded), splits in two.
+ case "$SHARD_PROFILE" in
+ standard) N_X86=3; N_ARM=3; N_WIN=2 ;;
+ pr) N_X86=1; N_ARM=2; N_WIN=1 ;;
+ *) echo "::error::unknown shard_profile '$SHARD_PROFILE'"; exit 1 ;;
+ esac
if [ "$SHARDS" = "true" ]; then
- UNIX=$(jq -c '[.[] | . as $e
- | (if ($e.os | startswith("ubuntu")) then 3 else 1 end) as $n
+ UNIX=$(jq -c --argjson x86 "$N_X86" --argjson arm "$N_ARM" '[.[] | . as $e
+ | (if ($e.os | startswith("ubuntu")) then
+ (if ($e.os | endswith("-arm")) then $arm else $x86 end)
+ else 1 end) as $n
| range(1; $n + 1) as $i
| $e + {shard: "\($i)/\($n)"}]' <<< "$UNIX")
- WIN=$(jq -c '[.[] | . as $e
- | range(1; 3) as $i
- | $e + {shard: "\($i)/2"}]' <<< "$WIN")
+ WIN=$(jq -c --argjson n "$N_WIN" '[.[] | . as $e
+ | range(1; $n + 1) as $i
+ | $e + {shard: "\($i)/\($n)"}]' <<< "$WIN")
fi
echo "unix={\"include\":$UNIX}" >> "$GITHUB_OUTPUT"
echo "windows={\"include\":$WIN}" >> "$GITHUB_OUTPUT"
+ echo "tsan={\"include\":$TSAN}" >> "$GITHUB_OUTPUT"
+ # GitHub rejects an empty matrix, so a job whose legs were all
+ # deselected is skipped through these flags instead.
+ echo "has_unix=$(jq 'length > 0' <<< "$UNIX")" >> "$GITHUB_OUTPUT"
+ echo "has_windows=$(jq 'length > 0' <<< "$WIN")" >> "$GITHUB_OUTPUT"
+ echo "has_tsan=$(jq 'length > 0' <<< "$TSAN")" >> "$GITHUB_OUTPUT"
test-unix:
needs: setup-matrix
+ if: ${{ needs.setup-matrix.outputs.has_unix == 'true' }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.setup-matrix.outputs.unix) }}
@@ -160,12 +221,24 @@ jobs:
ccache-test-${{ matrix.os }}-${{ matrix.cc }}-${{ github.ref }}-
ccache-test-${{ matrix.os }}-${{ matrix.cc }}-
+ - name: Fetch the test selection
+ # Only the Linux x86_64 leg narrows (user decision 2026-10-16): the
+ # coverage that justifies an omission comes from the Linux x86_64
+ # producer, so arm64, macOS and Windows code under #ifdef has static
+ # evidence only and those legs run every test.
+ if: ${{ inputs.test_selection != '' && runner.os == 'Linux' && runner.arch == 'X64' }}
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: ${{ inputs.test_selection }}
+ path: ${{ runner.temp }}/test-selection
+
- name: Test
run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
env:
CBM_TEST_SHARD: ${{ matrix.shard }}
CBM_TEST_LEG: ${{ matrix.os }}-${{ matrix.cc }}
CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }}
+ CBM_TEST_SELECTION_DIR: ${{ inputs.test_selection != '' && runner.os == 'Linux' && runner.arch == 'X64' && format('{0}/test-selection', runner.temp) || '' }}
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1500M
@@ -187,6 +260,23 @@ jobs:
# shard-completeness job still gates the cross-shard union.
if-no-files-found: warn
+ # The job log carries a red suite's SUMMARY; these are the complete logs
+ # behind it, so a failure can be read instead of re-run. The harness
+ # copies the log of every suite that did not end green, plus results.txt,
+ # into test-logs/failed/ (scripts/suite-failure-report.sh) — typically a
+ # few kilobytes for a red suite, not the ~25 MB of a whole run's logs.
+ # Runs only on a failed job. warn, not error: a job that failed OUTSIDE
+ # the suite wave (contract step, build, prod-binary guards) has no such
+ # directory, and a second error would only bury the first.
+ - name: Failing suite logs
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: suite-logs-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }}
+ path: build/c/test-logs/failed/
+ retention-days: 7
+ if-no-files-found: warn
+
- name: Compiler cache (save)
if: always() && (matrix.shard == null || startsWith(matrix.shard, '1/'))
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -205,6 +295,7 @@ jobs:
# inode/flock behavior and Windows handle/link behavior are each gating,
# without adding Node/Go provisioning to every sanitizer/container leg.
test-package-wrappers:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'pkg-wrappers') }}
strategy:
fail-fast: false
matrix:
@@ -231,6 +322,28 @@ jobs:
shell: bash
run: scripts/ci/test-package-wrappers.sh
+ # CodeAtlasWeb (graph-ui) leg: the frontend's own vitest suite (2000+
+ # tests; the render tests run under jsdom), its hardcoded-string and
+ # promise scans, its portable frozen acceptance checks, and a production
+ # build with the same npm ci the product build runs. One canonical script
+ # (scripts/ci/test-ui.sh) is the whole leg, here and locally. No browser
+ # is downloaded: the browser proofs (graph-ui/tools/smoke-*.mjs) need a
+ # running server and Playwright browsers, and they stay a local, manually
+ # dispatched venue documented in graph-ui/README.md rather than a hosted
+ # gate.
+ test-ui:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "22"
+
+ - name: CodeAtlasWeb leg (install, unit, style, promises, acceptance, build)
+ run: scripts/ci/test-ui.sh
+
# ThreadSanitizer data-race gate. The threaded production surfaces (watcher,
# subprocess, httpd, pipeline, mcp, worker pool, diagnostics, the runnable
# daemon-coordination paths) plus allocator concurrency all run under TSan
@@ -245,6 +358,7 @@ jobs:
# parallel suite — catches what an older compiler-rt can miss. Same
# canonical scripts/test.sh wave as every other leg.
test-diag:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'diag') }}
# Pinned for the same reason as the lint jobs: the pinned diagnostic
# toolchain below installs from llvm-toolchain-noble-21.
runs-on: ubuntu-24.04
@@ -282,6 +396,17 @@ jobs:
scripts/test.sh CC=clang-21 CXX=clang++-21 \
SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-optimize-sibling-calls"
+ # Same step as in test-unix: the complete logs of the suites that did
+ # not end green, only when the job failed.
+ - name: Failing suite logs
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: suite-logs-diag
+ path: build/c/test-logs/failed/
+ retention-days: 7
+ if-no-files-found: warn
+
- name: Compiler cache (save)
if: always()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -299,17 +424,21 @@ jobs:
# artifact — the local ladder cannot answer that, having no faithful x86-64
# emulation. It has now answered it, and the answer SPLIT the list:
#
- # - the five deep-recursion suites overflow their thread stacks on x86-64
- # too, so that limit is NOT architectural;
+ # - five suites aborted with "stack-overflow ... nested bug" on both
+ # architectures. That was not deep recursion: it was MSan crashing while
+ # it reported real uninitialized reads (reporting with the slow unwinder
+ # exhausted the main thread's stack). scripts/msan.sh now unwinds with
+ # frame pointers, the
+ # reports print, the reads are fixed, and the five suites run again;
# - `cli` does not overflow here at all — it fails in the install path for
# an unrelated, still-undiagnosed reason;
# - `incremental` was a shadow-memory RSS artifact, now fixed in the test
# itself rather than skipped, so it stays in the lane.
#
# Per-cause detail lives in scripts/msan.sh, which is the single authoritative
- # list both venues now share. Zero use-of-uninitialized-value findings were
- # reported across the lane on either architecture.
+ # list both venues now share.
test-msan:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'msan') }}
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
@@ -334,6 +463,7 @@ jobs:
# so this leg is the ordinary ASan suite built with that toolchain and run
# with detect_leaks=1. Mirrors the local `make -f Makefile.cbm test-lsan`.
test-lsan-macos:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lsan-macos') }}
runs-on: macos-latest
timeout-minutes: 120
steps:
@@ -364,6 +494,17 @@ jobs:
LLVM_PREFIX="$(brew --prefix llvm@22)"
scripts/test.sh CC="$LLVM_PREFIX/bin/clang" CXX="$LLVM_PREFIX/bin/clang++"
+ # Same step as in test-unix: the complete logs of the suites that did
+ # not end green, only when the job failed.
+ - name: Failing suite logs
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: suite-logs-lsan-macos
+ path: build/c/test-logs/failed/
+ retention-days: 7
+ if-no-files-found: warn
+
- name: Compiler cache (save)
if: always()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -372,13 +513,11 @@ jobs:
key: ccache-lsan-${{ github.ref }}-${{ github.sha }}
test-tsan:
+ needs: setup-matrix
+ if: ${{ needs.setup-matrix.outputs.has_tsan == 'true' }}
strategy:
fail-fast: false
- matrix:
- include:
- - { os: ubuntu-latest, cc: clang, cxx: clang++ }
- - { os: ubuntu-24.04-arm, cc: clang, cxx: clang++ }
- - { os: macos-14, cc: cc, cxx: c++ }
+ matrix: ${{ fromJSON(needs.setup-matrix.outputs.tsan) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
steps:
@@ -431,6 +570,7 @@ jobs:
test-windows:
needs: setup-matrix
+ if: ${{ needs.setup-matrix.outputs.has_windows == 'true' }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.setup-matrix.outputs.windows) }}
@@ -508,6 +648,8 @@ jobs:
CBM_TEST_SHARD: ${{ matrix.shard }}
CBM_TEST_LEG: ${{ matrix.os }}-${{ matrix.msystem }}
CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }}
+ # Windows legs run every test: see the Linux x86_64-only fetch in test-unix.
+ CBM_TEST_SELECTION_DIR: ''
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1500M
@@ -531,6 +673,17 @@ jobs:
# shard-completeness job still gates the cross-shard union.
if-no-files-found: warn
+ # Same step as in test-unix: the complete logs of the suites that did
+ # not end green, only when the job failed.
+ - name: Failing suite logs
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: suite-logs-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }}
+ path: build/c/test-logs/failed/
+ retention-days: 7
+ if-no-files-found: warn
+
# Shard 1 (or the unsharded job) saves the compiler cache; every shard
# builds the identical runner, so one cache carries the full object set.
- name: Compiler cache (save)
@@ -564,8 +717,12 @@ jobs:
with:
pattern: shard-manifest-*
path: manifests
+ # A PR with no test leg selected uploads no manifest; the script
+ # accepts that only when the selection really holds no leg.
- name: Verify cross-shard union per leg
- run: repo/scripts/ci/verify-shard-union.sh manifests
+ env:
+ LANES: ${{ inputs.lanes }}
+ run: repo/scripts/ci/verify-shard-union.sh manifests --lanes "$LANES"
# Windows product-surface regression guards. Distinct from test-windows above
# (the sanitizer C suite): these drive a real product binary + external UI pack
@@ -576,6 +733,7 @@ jobs:
# CLI arguments being mangled by the narrow-argv main() (#423/#20, fixed by the
# wide-argv entrypoint that reads GetCommandLineW).
test-windows-guards:
+ if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'windows-guards') }}
runs-on: windows-latest
timeout-minutes: 60
steps:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 2430c6712..621d407fc 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -9,8 +9,12 @@ on:
permissions:
contents: read
+# One analysis per pull request and one per pushed branch. A newer push to main
+# cancels the analysis of an older main commit; only the newest commit's
+# results matter for main, and a merge-heavy day otherwise queues one 40-100
+# minute analysis per merge ahead of every pull request run.
concurrency:
- group: codeql-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
+ group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 8cb464b19..815146253 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -1,7 +1,10 @@
-# PR validation: security gates + lint + full test suite. Builds, smoke and
-# soak stay maintainer-driven via the dry-run workflow_dispatch. Branch
+# PR validation: security gates + lint + the test legs this PR's changes need.
+# `changes` runs scripts/ci/select-lanes.sh on the PR's file list; every lane
+# job below runs only when selected (unknown paths, workflows, the harness,
+# Makefile.cbm and vendored code select everything). Builds, smoke and soak at
+# full breadth stay maintainer-driven via the dry-run workflow_dispatch. Branch
# protection requires `dco` + `ci-ok` — a single stable summary context that
-# fails unless every PR stage succeeded.
+# fails unless every PR stage succeeded and every selected lane ran green.
name: PR
on:
@@ -21,6 +24,7 @@ concurrency:
jobs:
security:
+ needs: [changes]
uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants,
@@ -30,33 +34,102 @@ jobs:
security-events: read
actions: read
secrets: inherit
+ with:
+ lanes: ${{ needs.changes.outputs.lanes }}
lint:
+ needs: [changes]
+ if: ${{ contains(fromJSON(needs.changes.outputs.lanes), 'lint') || contains(fromJSON(needs.changes.outputs.lanes), 'lint-mem') }}
uses: ./.github/workflows/_lint.yml
+ with:
+ lanes: ${{ needs.changes.outputs.lanes }}
+
+ # ── Smart CI, PR CI only: run only the tests this change can reach.
+ # Active only while the repository variable TEST_IMPACT_MODE is `gate`;
+ # unset (shadow) every test runs and test-impact-shadow only reports.
+ # test-impact-select.sh --gate narrows only on a well-formed answer: a
+ # failure of this job, a run-all answer or a lane that selects nothing
+ # leaves `test` without a selection, and every test runs. ci-ok does not
+ # need this job: when it breaks, PRs cost more minutes, never coverage.
+ # Dry runs and releases never pass a selection.
+ select-tests:
+ needs: [changes]
+ if: ${{ vars.TEST_IMPACT_MODE == 'gate' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml)
+ outputs:
+ selection: ${{ steps.select.outputs.selection }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # Full history: the engine certifies the history it diffs and refuses
+ # a shallow clone, so a depth-2 checkout always answered `all`.
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Build this checkout's engine
+ run: |
+ sudo apt-get update && sudo apt-get install -y zlib1g-dev
+ scripts/build.sh CC=gcc CXX=g++
+ - name: Select the tests
+ id: select
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection"
+ - name: Upload the selection
+ if: ${{ steps.select.outputs.selection == 'narrowed' }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-selection
+ path: ${{ runner.temp }}/test-selection
+ if-no-files-found: error
+ overwrite: true
+ # The selection a veto discarded (written only when one did): evidence
+ # for the vetoes, never read by a test leg.
+ - name: Upload the vetoed selection
+ if: ${{ !cancelled() && steps.select.outcome != 'skipped' }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-selection-vetoed
+ path: ${{ runner.temp }}/test-impact/selection-vetoed.json
+ if-no-files-found: ignore
+ overwrite: true
test:
- needs: [lint]
+ needs: [changes, lint, select-tests]
if: ${{ !cancelled() && needs.lint.result == 'success' }}
uses: ./.github/workflows/_test.yml
with:
+ lanes: ${{ needs.changes.outputs.lanes }}
+ # Smart CI: only a successful select-tests run that narrowed passes a
+ # selection; skipped (shadow), failed, run-all and nothing-selected
+ # answers all run every test.
+ test_selection: ${{ needs.select-tests.result == 'success' && needs.select-tests.outputs.selection == 'narrowed' && 'test-selection' || '' }}
# Perf assertions are timing-sensitive on shared runners; they stay in
# dry runs and releases where a flaky red does not block a merge.
skip_perf: true
- # Iteration speed: split each C-suite leg across parallel shard jobs
- # (ubuntu x3, windows x2; coverage re-proven every run by the
- # shard-completeness job). Dry runs and releases shard identically.
+ # Iteration speed: split C-suite legs across parallel shard jobs
+ # (coverage re-proven every run by the shard-completeness job). The PR
+ # profile shards only arm64 (x2); x86 and Windows run whole, since each
+ # shard re-pays its setup. Dry runs and releases keep x3 / x2.
shard_suites: true
+ shard_profile: pr
- # ── Which files changed? Gate the (heavier) build+smoke on product code so
- # docs / CI / test-only PRs stay fast. ──
+ # ── Which lanes does this PR need? The changed files go through
+ # scripts/ci/select-lanes.sh (tier, lanes, full, reasons); docs / CI /
+ # test-only PRs stay fast, anything it cannot place runs everything. ──
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
- product: ${{ steps.f.outputs.product }}
+ tier: ${{ steps.select.outputs.tier }}
+ full: ${{ steps.select.outputs.full }}
+ lanes: ${{ steps.select.outputs.lanes }}
+ smoke: ${{ steps.select.outputs.smoke }}
steps:
- - name: Detect product-code changes
- id: f
+ - name: List the changed files
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
@@ -64,7 +137,9 @@ jobs:
run: |
# The full .diff endpoint rejects large-but-valid PRs at 20k lines.
# The paginated files endpoint remains filename-only for this gate.
- if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename'); then
+ # A rename lists its old path too, as the fallback's --no-renames
+ # diff does: moving a file away is a change to where it was.
+ if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[] | .filename, (.previous_filename // empty)'); then
echo "file list: pulls/files API"
else
# The files endpoint renders the diff server-side too, and answers
@@ -81,36 +156,57 @@ jobs:
"https://github.com/$REPO" "refs/pull/$PR/merge"
FILES=$(git --git-dir="$CHANGES_GIT" diff --name-only --no-renames FETCH_HEAD^1 FETCH_HEAD)
fi
- printf '%s\n' "$FILES"
- # A here-string, not a pipe: under this shell's pipefail, grep -q
- # exits on its first match, printf dies of SIGPIPE once the list
- # outgrows the pipe buffer, and the MATCH is then reported as a
- # failure -- product=false, smoke skipped, on the largest PRs.
- if grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)' <<<"$FILES"; then
- echo "product=true" >> "$GITHUB_OUTPUT"
- else
- echo "product=false" >> "$GITHUB_OUTPUT"
- fi
+ printf '%s\n' "$FILES" > "$RUNNER_TEMP/changed-files.txt"
+ cat "$RUNNER_TEMP/changed-files.txt"
+
+ # The selector is PR code: it runs in its own step, after the
+ # token-bearing one, from a checkout that keeps no credentials.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ # The pulls/files API lists at most 3000 files: the PR's own count lets
+ # the selector notice a cut list and select everything instead.
+ - name: Select lanes
+ id: select
+ env:
+ CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
+ run: |
+ scripts/ci/select-lanes.sh --format github-output --expect-files "$CHANGED_FILES" "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt"
+ cat "$RUNNER_TEMP/selection.txt"
+ cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT"
+ # pr-smoke's legs, each tagged with its lane (kept in the job name,
+ # where ci-ok reads it back); the job gets the selected ones.
+ SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]'
+ LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt")
+ echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT"
# ── Light smoke: build the PRODUCTION binary and run the core smoke on the
# three RELIABLE NATIVE platforms. Catches built-binary regressions at PR
# time (e.g. the Windows CreateProcess argv-quoting class that previously
# only surfaced in the release dry run). The full broad/emulated smoke stays
- # in the dry run. Only product-code PRs pay this; every leg gates.
+ # in the dry run. Only the selected legs run (ubuntu from the extraction
+ # tier up, all three for platform/packaging changes); every leg gates.
# The maintained local wrappers add a race-free release fixture so the
# download/checksum/install/update phases run here as well. ──
pr-smoke:
needs: [changes]
- if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }}
+ if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'smoke-ubuntu') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-mac') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-win')) }}
strategy:
fail-fast: false
- matrix:
- os: [ubuntu-latest, macos-14, windows-latest]
+ matrix: ${{ fromJSON(needs.changes.outputs.smoke) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper; the
+ # macOS runner images have none on PATH.
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: pkg/go/go.mod
+ cache: false
+
- name: Install deps (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache
@@ -153,6 +249,7 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
+ SMOKE_REQUIRE_WRAPPERS: "1"
- name: Build prod + smoke (macOS)
if: matrix.os == 'macos-14'
@@ -163,6 +260,7 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
+ SMOKE_REQUIRE_WRAPPERS: "1"
- name: Build prod + smoke (Windows)
if: matrix.os == 'windows-latest'
@@ -173,31 +271,123 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
+ SMOKE_REQUIRE_WRAPPERS: "1"
# ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and
# the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs:
# it uploads evidence and prints the ratchet, it never blocks a merge. Only
- # product-code PRs pay for it. ──
+ # PRs whose selection holds the memwaste lane (memory core, memwaste
+ # tooling, full runs) pay for it. ──
memwaste:
needs: [changes]
- if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }}
+ if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'memwaste') }}
uses: ./.github/workflows/_memwaste.yml
with:
mode: pr
+ # ── Test-impact SHADOW, REPORT-ONLY: predicts from the code graph which C
+ # suites this change can affect (the latest released binary indexes the
+ # PR checkout; detect_changes at depth 15 against the base, mapped to
+ # suites, with the selector's run-all triggers) and uploads the
+ # prediction, so graph-based selection can be judged against what really
+ # failed before anything gates on it. continue-on-error and NOT in
+ # ci-ok's needs: it can never block or pass a merge. ──
+ test-impact-shadow:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ continue-on-error: true
+ permissions:
+ contents: read
+ actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml)
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # Full history: the engine (test-impact select) certifies the history
+ # it diffs and refuses a shallow clone ("Graft or shallow state
+ # prevents history certification"), which made every shadow answer
+ # run_all (GIT_UNAVAILABLE). About 400 MiB, this job only.
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Fetch the latest released binary
+ id: release
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: scripts/ci/test-impact-shadow.sh --fetch-release "$RUNNER_TEMP/cbm-bin"
+ - name: Build the binary from this checkout (download failed)
+ if: ${{ steps.release.outcome == 'failure' }}
+ run: |
+ sudo apt-get update && sudo apt-get install -y zlib1g-dev
+ scripts/build.sh CC=gcc CXX=g++
+ mkdir -p "$RUNNER_TEMP/cbm-bin"
+ cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/"
+ - name: Predict the impacted suites
+ run: scripts/ci/test-impact-shadow.sh --binary "$RUNNER_TEMP/cbm-bin/codebase-memory-mcp" --out "$RUNNER_TEMP/test-impact"
+ # The engine (test-impact select) is this checkout's own code: the
+ # released binary predates it, so the checkout is built either way.
+ - name: Build this checkout's engine
+ if: ${{ steps.release.outcome != 'failure' }}
+ run: |
+ sudo apt-get update && sudo apt-get install -y zlib1g-dev
+ scripts/build.sh CC=gcc CXX=g++
+ - name: Select with the team artifact (engine shadow)
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact"
+ - name: Upload the prediction
+ if: ${{ always() }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-impact-prediction
+ path: |
+ ${{ runner.temp }}/test-impact/prediction.json
+ ${{ runner.temp }}/test-impact/selection.json
+ ${{ runner.temp }}/test-impact/changed-files.txt
+ ${{ runner.temp }}/test-impact/selection-engine.json
+ ${{ runner.temp }}/test-impact/summary-engine.md
+ if-no-files-found: warn
+
+ # ── Docs-only and non-product PRs (tiers T0a, T0b) run the static contract
+ # steps of the canonical test leg on their own: they police docs
+ # surfaces (language counts, version metadata), test-infrastructure/ and
+ # lint config too. Every test leg runs the same steps. ──
+ contracts:
+ needs: [changes]
+ if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'contracts') }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Contract steps only
+ run: scripts/test.sh --contracts-only
+
ci-ok:
# The one required context (besides dco) — fails unless every PR stage
- # succeeded, so matrix renames can never silently deadlock merges. `skipped`
- # is OK: pr-smoke skips on docs/CI/test-only PRs (changes.product == false).
- needs: [security, lint, test, changes, pr-smoke]
+ # succeeded, so matrix renames can never silently deadlock merges, and
+ # every lane the selector chose ran green: `skipped` is OK only for a lane
+ # nobody selected (a selected lane that skipped is a silent gate loss).
+ # memwaste and the test-impact shadow are report-only and not needed here.
+ needs: [changes, security, lint, test, pr-smoke, contracts]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
+ # actions:read lists this run's jobs, the lane-level truth behind `needs`.
+ permissions:
+ contents: read
+ actions: read
steps:
# Needs a checkout (unlike before): the gate logic lives in the canonical
# scripts/ci entry, not inline YAML (venue-parity contract).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - name: All PR stages must have succeeded
+ - name: List this run's jobs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=latest&per_page=100" \
+ --jq '.jobs[] | {name, status, conclusion}' > "$RUNNER_TEMP/jobs.jsonl"
+ - name: All PR stages and every selected lane must have succeeded
env:
RESULTS: ${{ toJSON(needs) }}
+ LANES: ${{ needs.changes.outputs.lanes }}
+ JOBS: ${{ runner.temp }}/jobs.jsonl
run: scripts/ci/require-all-green.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84e55a97c..134b425e2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -283,9 +283,15 @@ jobs:
# move the release tag. (A floating HEAD also broke the 0.8.0 release:
# HEAD had become a commit touching .github/workflows/, and the App
# token may not create refs pointing at workflow-modifying commits.)
+ # The Go wrapper is a nested module: `go install
+ # .../pkg/go/cmd/codebase-memory-mcp@vX.Y.Z` and @latest resolve its
+ # versions from pkg/go/v* tags only (without one, Go fetches the whole
+ # repository, which is over its 500 MB module limit).
run: |
git tag -f "$VERSION" "$GITHUB_SHA"
git push origin "$VERSION" --force
+ git tag -f "pkg/go/$VERSION" "$GITHUB_SHA"
+ git push origin "pkg/go/$VERSION" --force
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
@@ -510,6 +516,12 @@ jobs:
else
npm publish --access public --provenance
fi
+ # `next` is "the newest you can opt into": once a stable release
+ # exists, an older RC must not stay behind it (0.9.1-rc.1 did until
+ # v0.12.1). Re-runnable: dist-tag add is idempotent.
+ if [[ "$RELEASE_VERSION" != *-* ]]; then
+ npm dist-tag add "codebase-memory-mcp@$V" next
+ fi
# ── PyPI ─────────────────────────────────────────────────
- name: Setup Python
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index c2acbd8ac..fb0a52bbc 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -8,6 +8,12 @@ on:
permissions: read-all
+# Only the newest main commit is scored: a newer push (or the weekly run)
+# cancels a pending or running score of an older commit.
+concurrency:
+ group: scorecard-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
scorecard:
runs-on: ubuntu-latest
diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml
new file mode 100644
index 000000000..4db216ecd
--- /dev/null
+++ b/.github/workflows/test-impact-artifact.yml
@@ -0,0 +1,87 @@
+# Team artifact for test selection (smart CI, report-only until the oracles
+# and the shadow comparison are green). Every push to main publishes the
+# commit's bundle — its frozen graph, built incrementally from the previous
+# bundle, and its per-test coverage map, re-run only for the suites the push
+# selects — as the Actions artifact test-impact-. A weekly run rebuilds
+# both from scratch and fails when the incremental graph differs from the full
+# one. PR selection fetches the bundle of its merge base
+# (scripts/ci/test-impact-bundle.sh) and never narrows on anything else.
+name: test-impact-artifact
+
+on:
+ push:
+ branches: [main]
+ schedule:
+ - cron: "23 3 * * 0" # weekly full refresh, Sunday 03:23 UTC
+ workflow_dispatch:
+ inputs:
+ mode:
+ description: incremental or full
+ type: choice
+ options: [incremental, full]
+ default: full
+
+permissions:
+ contents: read
+ actions: read # fetch the previous bundle
+
+concurrency:
+ # Bundles chain: each push builds on the previous one, so runs never overlap
+ # and are never cancelled half-way.
+ group: test-impact-artifact
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 120
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # Full history: `test-impact select/publish` certify the history they
+ # read and refuse a shallow clone.
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Install build deps and LLVM 21 (coverage toolchain)
+ run: |
+ sudo apt-get update && sudo apt-get install -y zlib1g-dev
+ wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
+ echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list
+ sudo apt-get update
+ sudo apt-get install -y clang-21 llvm-21 libclang-rt-21-dev
+
+ - name: Build the production binary and the coverage runner
+ run: |
+ scripts/build.sh CC=gcc CXX=g++
+ make -f Makefile.cbm test-runner-cov COV_LLVM_BIN=/usr/lib/llvm-21/bin -j"$(nproc)"
+
+ - name: Publish the bundle
+ env:
+ GH_TOKEN: ${{ github.token }}
+ EVENT: ${{ github.event_name }}
+ BEFORE: ${{ github.event.before }}
+ REQUESTED: ${{ inputs.mode }}
+ # The selection lane excludes @perf and PR CI skips perf, so their
+ # (slowest, longest under coverage) suites are neither mapped nor
+ # listed: the runner then neither lists nor runs them.
+ CBM_SKIP_PERF: "1"
+ run: |
+ mode=incremental
+ if [ "$EVENT" = schedule ] || [ "$REQUESTED" = full ]; then mode=full; fi
+ before="$(git rev-parse HEAD^1)"
+ if [ "$EVENT" = push ] && [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
+ before="$BEFORE"
+ fi
+ scripts/ci/test-impact-publish.sh --binary build/c/codebase-memory-mcp \
+ --runner build/cov/test-runner --llvm-bin /usr/lib/llvm-21/bin \
+ --out "$RUNNER_TEMP/bundle" --mode "$mode" --before "$before"
+
+ - name: Upload the bundle
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-impact-${{ github.sha }}
+ path: ${{ runner.temp }}/bundle
+ include-hidden-files: true # the graph lives in .codebase-memory/
+ retention-days: 30
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 3705501c2..1533c93e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,9 @@ graph-ui/dist/
# Generated by scripts/embed-frontend.sh into the source tree (the linker needs
# it beside the other UI sources). Removed by scripts/clean.sh; never committed.
src/ui/embedded_assets.c
+# Raw coverage profiles. A coverage-instrumented binary (make test-runner-cov,
+# cbm-cov) run without LLVM_PROFILE_FILE drops default.profraw in its cwd.
+*.profraw
# Generated reports
BENCHMARK_REPORT.md
diff --git a/Makefile.cbm b/Makefile.cbm
index d76269f66..6d19f430a 100644
--- a/Makefile.cbm
+++ b/Makefile.cbm
@@ -71,13 +71,33 @@ ifeq ($(TEST_SEAMS),1)
TEST_SEAM_DEFINE := -DCBM_ENABLE_TEST_SEAMS=1
endif
+# Per-test coverage for the test-impact map. TEST_COVERAGE=1 compiles clang
+# source coverage into OUR code only -- the one-shot sources, the LSP unity
+# object and the preprocessor -- in the test runner and in the product binary,
+# and turns on the runner's per-test profile hooks (tests/test_framework.h).
+# Vendored objects stay uninstrumented: a vendored change selects tests by rule,
+# and every counter compiled in is written out again for every test. Like every
+# other flavour it needs its own BUILD_DIR; the test-runner-cov and cbm-cov
+# targets pin one.
+# -fprofile-continuous: a child process started with `%c` in LLVM_PROFILE_FILE
+# keeps its counters mapped onto its profile file, so a child that is killed
+# still leaves a complete profile. Linux needs the flag for `%c` to work at
+# all; with it macOS behaves the same way (tests/test_main.c, "Children").
+TEST_COVERAGE ?= 0
+TEST_COVERAGE_FLAGS :=
+TEST_COVERAGE_DEFINE :=
+ifeq ($(TEST_COVERAGE),1)
+TEST_COVERAGE_FLAGS := -fprofile-instr-generate -fcoverage-mapping -fprofile-continuous
+TEST_COVERAGE_DEFINE := -DCBM_TEST_COVERAGE=1
+endif
+
# Production flags (CFLAGS_EXTRA allows CI to inject -DCBM_VERSION)
# CBM_BIND_TS_ALLOCATOR=1: bind the tree-sitter runtime to mimalloc (#424). Only
# the prod build uses mimalloc (MI_OVERRIDE=1); the test build is CRT+ASan, where
# binding would create an alloc/free mismatch, so the guard is prod-only.
CFLAGS_PROD = $(CFLAGS_COMMON) -O2 -DCBM_BIND_TS_ALLOCATOR=1 $(TEST_SEAM_DEFINE) \
- $(GLOBAL_OVERRIDE_DEFINE) $(MEMWASTE_CFLAGS) $(CFLAGS_EXTRA)
-CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS)
+ $(GLOBAL_OVERRIDE_DEFINE) $(MEMWASTE_CFLAGS) $(TEST_COVERAGE_FLAGS) $(CFLAGS_EXTRA)
+CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS) $(TEST_COVERAGE_FLAGS)
# Test flags: debug + sanitizers (override SANITIZE= to disable on Windows)
SANITIZE = -fsanitize=address,undefined -fno-omit-frame-pointer
@@ -102,8 +122,10 @@ INCREMENTAL_TEST_DEFINE = -DCBM_INCREMENTAL_TEST_API=1
COVERAGE_MARKER_TEST_DEFINE = -DCBM_COVERAGE_MARKER_TEST_API=1
CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(SANITIZED_DEFINE) \
$(KOTLIN_DEDUP_TEST_DEFINE) $(CALL_REFERENCE_LOOKUP_TEST_DEFINE) \
- $(INCREMENTAL_TEST_DEFINE) $(COVERAGE_MARKER_TEST_DEFINE) -g -O1 $(SANITIZE)
-CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(CXX_STDLIB_FLAGS)
+ $(INCREMENTAL_TEST_DEFINE) $(COVERAGE_MARKER_TEST_DEFINE) $(TEST_COVERAGE_DEFINE) \
+ -g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS)
+CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS) \
+ $(CXX_STDLIB_FLAGS)
# TSan (can't combine with ASan)
TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer
@@ -309,6 +331,7 @@ FOUNDATION_SRCS = \
src/foundation/str_util.c \
src/foundation/workspace.c \
src/foundation/platform.c \
+ src/foundation/path_syntax_internal.c \
src/foundation/system_info.c \
src/foundation/slab_alloc.c \
src/foundation/yaml.c \
@@ -343,6 +366,7 @@ EXTRACTION_SRCS = \
$(CBM_DIR)/extract_imports.c \
$(CBM_DIR)/extract_usages.c \
$(CBM_DIR)/extract_unified.c \
+ $(CBM_DIR)/extract_url_fold.c \
$(CBM_DIR)/extract_semantic.c \
$(CBM_DIR)/extract_type_refs.c \
$(CBM_DIR)/extract_type_assigns.c \
@@ -350,14 +374,39 @@ EXTRACTION_SRCS = \
$(CBM_DIR)/extract_channels.c \
$(CBM_DIR)/extract_k8s.c \
$(CBM_DIR)/extract_dbt.c \
+ $(CBM_DIR)/doclink.c \
+ $(CBM_DIR)/doclink_cs.c \
+ $(CBM_DIR)/doclink_md.c \
+ $(CBM_DIR)/doclink_rst.c \
+ $(CBM_DIR)/doclink_py.c \
+ $(CBM_DIR)/doc_adr.c \
+ $(CBM_DIR)/doc_pdf.c \
+ $(CBM_DIR)/doc_adoc.c \
+ $(CBM_DIR)/pdf/pdf_obj.c \
+ $(CBM_DIR)/pdf/pdf_font.c \
+ $(CBM_DIR)/pdf/pdf_text.c \
+ $(CBM_DIR)/pdf/pdf_tables.c \
+ $(CBM_DIR)/pdf/pdf_unicode.c \
+ $(CBM_DIR)/doclink_go.c \
+ $(CBM_DIR)/doclink_jvm.c \
+ $(CBM_DIR)/doclink_rs.c \
+ $(CBM_DIR)/doclink_perl.c \
+ $(CBM_DIR)/doclink_ts.c \
+ $(CBM_DIR)/doclink_php.c \
+ $(CBM_DIR)/doclink_c.c \
+ $(CBM_DIR)/diagram.c \
+ $(CBM_DIR)/route_path.c \
+ $(CBM_DIR)/openapi.c \
$(CBM_DIR)/sql_values.c \
$(CBM_DIR)/helpers.c \
+ $(CBM_DIR)/cpp_branch_views.c \
$(CBM_DIR)/result_compact.c \
$(CBM_DIR)/result_spill.c \
$(CBM_DIR)/lang_specs.c \
$(CBM_DIR)/macro_table.c \
$(CBM_DIR)/iris_export_xml.c \
- $(CBM_DIR)/service_patterns.c
+ $(CBM_DIR)/service_patterns.c \
+ $(CBM_DIR)/spawn_patterns.c
# LSP resolvers (compiled as one unit via lsp_all.c)
LSP_SRCS = $(CBM_DIR)/lsp_all.c
@@ -400,13 +449,15 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp
SQLITE_WRITER_SRC = $(CBM_DIR)/sqlite_writer.c
# Store module (new)
-STORE_SRCS = src/store/store.c
+STORE_SRCS = src/store/store.c src/store/graph_mem.c src/store/architecture_projection.c src/store/store_impact.c src/store/store_graph_digest.c src/store/store_content_digest.c
# Cypher module (new)
CYPHER_SRCS = src/cypher/cypher.c
# MCP server module (new)
-MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c
+MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c src/mcp/architecture_jobs.c \
+ src/mcp/test_impact.c src/mcp/test_impact_diff.c src/mcp/test_impact_classify.c src/mcp/test_impact_source.c src/mcp/test_impact_seed.c src/mcp/test_impact_engine.c src/mcp/test_impact_engine_snapshot.c src/mcp/test_impact_engine_reach.c src/mcp/test_impact_engine_artifact.c src/mcp/test_impact_cli.c src/mcp/test_impact_artifact.c src/mcp/test_impact_changes.c src/mcp/test_impact_origins.c src/mcp/test_impact_coverage.c src/mcp/test_impact_config.c src/mcp/test_impact_select.c src/mcp/test_impact_policy.c src/mcp/test_impact_git.c src/mcp/test_impact_inventory.c src/mcp/test_impact_inventory_input.c src/mcp/test_impact_tree.c src/mcp/test_impact_tree_audit.c src/mcp/test_impact_tree_read.c src/mcp/test_impact_tree_batch.c src/mcp/test_impact_tree_digest.c src/mcp/test_impact_tree_names.c src/mcp/test_impact_tree_plan.c src/mcp/test_impact_tree_posix.c src/mcp/test_impact_tree_posix_acl.c src/mcp/test_impact_tree_posix_io.c src/mcp/test_impact_tree_util.c src/mcp/test_impact_tree_windows.c src/mcp/test_impact_tree_windows_acl.c src/mcp/test_impact_tree_windows_io.c \
+ src/mcp/test_impact_result.c src/mcp/test_impact_result_json.c src/mcp/test_impact_result_project.c src/mcp/test_impact_result_receipt.c src/mcp/test_impact_result_rows.c src/mcp/test_impact_result_util.c src/mcp/test_impact_result_validate.c src/mcp/test_impact_result_wire.c
# Shared MCP daemon coordination and local transport
DAEMON_SRCS = \
@@ -425,7 +476,18 @@ DAEMON_SRCS = \
DISCOVER_SRCS = \
src/discover/language.c \
src/discover/userconfig.c \
+ src/discover/test_conventions.c \
src/discover/gitignore.c \
+ src/discover/gitignore_checked.c \
+ src/discover/inventory_filter.c \
+ src/discover/inventory.c \
+ src/discover/inventory_guard.c \
+ src/discover/inventory_metadata.c \
+ src/discover/inventory_directories.c \
+ src/discover/inventory_controls.c \
+ src/discover/gitignore_checked_parse.c \
+ src/discover/gitignore_core.c \
+ src/discover/gitignore_guard.c \
src/discover/discover.c
# Graph buffer module (new)
@@ -445,6 +507,7 @@ PIPELINE_SRCS = \
src/pipeline/pass_parallel.c \
src/pipeline/pass_definitions.c \
src/pipeline/pass_calls.c \
+ src/pipeline/pass_spawns.c \
src/pipeline/pass_lsp_cross.c \
src/pipeline/pass_usages.c \
src/pipeline/pass_semantic.c \
@@ -454,12 +517,33 @@ PIPELINE_SRCS = \
src/pipeline/pass_configures.c \
src/pipeline/pass_configlink.c \
src/pipeline/pass_doclinks.c \
+ src/pipeline/doc_links.c \
+ src/pipeline/doc_links_cs.c \
+ src/pipeline/doc_links_md.c \
+ src/pipeline/doc_links_pdf.c \
+ src/pipeline/doc_links_rst.c \
+ src/pipeline/doc_links_adoc.c \
+ src/pipeline/doc_links_go.c \
+ src/pipeline/doc_links_jvm.c \
+ src/pipeline/doc_links_rs.c \
+ src/pipeline/doc_links_perl.c \
+ src/pipeline/doc_links_ts.c \
+ src/pipeline/doc_links_php.c \
+ src/pipeline/doc_links_c.c \
+ src/pipeline/pass_diagrams.c \
+ src/pipeline/doc_links_msbuild.c \
src/pipeline/pass_route_nodes.c \
src/pipeline/pass_enrichment.c \
- src/pipeline/pass_envscan.c \
src/pipeline/pass_compile_commands.c \
src/pipeline/pass_infrascan.c \
src/pipeline/pass_k8s.c \
+ src/pipeline/pass_operator.c \
+ src/pipeline/pass_shell.c \
+ src/pipeline/pass_terraform.c \
+ src/pipeline/pass_ansible.c \
+ src/pipeline/pass_docker.c \
+ src/pipeline/pass_envjoin.c \
+ src/pipeline/pass_ci.c \
src/pipeline/pass_similarity.c \
src/pipeline/pass_semantic_edges.c \
src/pipeline/pass_complexity.c \
@@ -488,7 +572,7 @@ WATCHER_SRCS = src/watcher/watcher.c
GIT_SRCS = src/git/git_context.c
# CLI module (new)
-CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
+CLI_SRCS = src/cli/cli.c src/cli/runtime_settings.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
src/cli/agent_clients.c src/cli/agent_profiles.c \
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
src/cli/config_text_edit.c src/cli/config_edit_path.c src/cli/activation_transaction.c
@@ -497,7 +581,16 @@ CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/
UI_SRCS = \
src/ui/config.c \
src/ui/http_server.c \
+ src/ui/activity.c \
+ src/ui/atlas_repository.c \
src/ui/layout3d.c \
+ src/ui/layout_regions.c \
+ src/ui/atlas_api.c \
+ src/ui/atlas_flows.c \
+ src/ui/atlas_handout.c \
+ src/ui/atlas_why.c \
+ src/ui/atlas_metrics.c \
+ src/ui/atlas_impact.c \
src/ui/httpd.c \
src/ui/embedded_stub.c
@@ -617,6 +710,8 @@ PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h $(CBM_DIR)/lsp/*.h te
TEST_FOUNDATION_SRCS = \
tests/test_main.c \
+ tests/test_coverage_children.c \
+ tests/test_coverage_parent_failure.c \
tests/test_arena.c \
tests/test_hash_table.c \
tests/test_dyn_array.c \
@@ -636,6 +731,7 @@ TEST_FOUNDATION_SRCS = \
TEST_EXTRACTION_SRCS = \
tests/test_extraction.c \
tests/test_callable_sig.c \
+ tests/test_test_conventions.c \
tests/test_extraction_inheritance.c \
tests/test_extraction_imports.c \
tests/test_parse_coverage.c \
@@ -647,8 +743,13 @@ TEST_EXTRACTION_SRCS = \
TEST_STORE_SRCS = \
tests/test_store_nodes.c \
tests/test_store_edges.c \
+ tests/test_store_impact.c \
+ tests/test_store_scope.c \
+ tests/test_store_graph_digest.c \
+ tests/test_graph_mem.c \
tests/test_store_search.c \
tests/test_store_arch.c \
+ tests/test_architecture_projection.c \
tests/test_store_bulk.c \
tests/test_store_pragmas.c \
tests/test_store_checkpoint.c \
@@ -659,6 +760,23 @@ TEST_CYPHER_SRCS = \
TEST_MCP_SRCS = \
tests/test_mcp.c \
+ tests/test_test_impact.c \
+ tests/test_test_impact_profiles.c \
+ tests/test_test_impact_changes.c \
+ tests/test_test_impact_origins.c \
+ tests/test_test_impact_git.c \
+ tests/test_test_impact_tree.c \
+ tests/test_test_impact_inventory.c \
+ tests/test_test_impact_classify.c \
+ tests/test_test_impact_source.c \
+ tests/test_test_impact_seed.c \
+ tests/test_test_impact_engine.c \
+ tests/test_test_impact_artifact.c \
+ tests/test_test_impact_inventory_errors.c \
+ tests/test_test_impact_inventory_support.c \
+ tests/test_test_impact_runner_filter.c \
+ tests/test_test_impact_runner_filter_fixture.c \
+ tests/test_test_impact_runner_filter_process.c \
tests/test_index_supervisor.c
TEST_DAEMON_SRCS = \
@@ -676,12 +794,23 @@ TEST_DISCOVER_SRCS = \
tests/test_language.c \
tests/test_userconfig.c \
tests/test_gitignore.c \
+ tests/test_gitignore_checked.c \
+ tests/if_native_git_path.c \
+ tests/test_inventory_filter.c \
+ tests/test_inventory_filter_cases.c \
+ tests/test_inventory_filter_errors.c \
+ tests/test_inventory_filter_limits.c \
+ tests/test_inventory_filter_native.c \
+ tests/test_inventory_filter_native_fixture.c \
+ tests/test_inventory_filter_native_platform.c \
+ tests/test_inventory_filter_structure_extra.c \
+ tests/test_inventory_filter_support.c \
tests/test_git_context.c \
tests/test_discover.c
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
-TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
+TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_doc_links_rst.c tests/test_doc_links_adoc.c tests/test_doc_mentions_go.c tests/test_doc_mentions_jvm.c tests/test_doc_mentions_rs.c tests/test_doc_mentions_perl.c tests/test_doc_mentions_ts.c tests/test_doc_mentions_php.c tests/test_doc_mentions_c.c tests/test_diagrams.c tests/test_openapi.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_WATCHER_SRCS = tests/test_watcher.c
@@ -717,15 +846,15 @@ TEST_PY_LSP_SCALE_SRCS = tests/test_py_lsp_scale.c
TEST_TS_LSP_SRCS = tests/test_ts_lsp.c
TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c
-TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c
+TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c tests/test_kotlin_typed_calls.c
TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c
-TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c
+TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c tests/test_conditional_variants.c tests/test_spawns.c
TEST_TRACES_SRCS = tests/test_traces.c
-TEST_CLI_SRCS = tests/test_cli.c tests/test_agent_clients.c tests/test_agent_profiles.c \
+TEST_CLI_SRCS = tests/test_cli.c tests/test_runtime_settings.c tests/test_agent_clients.c tests/test_agent_profiles.c \
tests/test_config_json_like.c \
tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c \
tests/test_activation_transaction.c
@@ -860,7 +989,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
# ── Targets ──────────────────────────────────────────────────────
-.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security fuzz
+.PHONY: test-coverage-flush test test-par test-repro test-foundation test-tsan test-daemon-smoke test-runner-cov cbm-cov cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security fuzz
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
@@ -871,7 +1000,7 @@ $(BUILD_DIR)/test-foundation: $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(PROJE
$(CC) $(CFLAGS_TEST) -o $@ $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(LDFLAGS_TEST)
test-foundation: $(BUILD_DIR)/test-foundation
- cd $(CURDIR) && $(BUILD_DIR)/test-foundation
+ cd "$(CURDIR)" && $(BUILD_DIR)/test-foundation
# ── Grammar/TS/LSP object files (compiled with relaxed warnings) ─
@@ -899,7 +1028,7 @@ $(BUILD_DIR)/ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $<
$(BUILD_DIR)/lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR)
- $(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) -c -o $@ $<
+ $(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) $(TEST_COVERAGE_FLAGS) -c -o $@ $<
$(BUILD_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR)
$(CXX) $(CXXFLAGS_TEST) -w -I$(CBM_DIR)/vendored -c -o $@ $<
@@ -1030,13 +1159,13 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_
$(LDFLAGS_TEST)
test: $(BUILD_DIR)/test-runner
- cd $(CURDIR) && $(BUILD_DIR)/test-runner
+ cd "$(CURDIR)" && $(BUILD_DIR)/test-runner
# Parallel variant: every suite as its own process; identical gate quality
# (see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh — union guard
# against --list-suites + aggregated pass/fail/skip totals).
test-par: $(BUILD_DIR)/test-runner
- cd $(CURDIR) && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
+ cd "$(CURDIR)" && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
# Focused native development is intentionally a separate, explicit target so
# an inherited TEST_SUITES value cannot silently narrow the gating test target.
@@ -1044,7 +1173,7 @@ TEST_SUITES ?=
test-focused: $(BUILD_DIR)/test-runner
@test -n "$(strip $(TEST_SUITES))" || \
(echo "TEST_SUITES is required for test-focused"; exit 2)
- cd $(CURDIR) && $(BUILD_DIR)/test-runner $(TEST_SUITES)
+ cd "$(CURDIR)" && $(BUILD_DIR)/test-runner $(TEST_SUITES)
# ── Cumulative bug-reproduction runner (RED board by design, non-gating) ──
# Mirrors test-runner's link line but uses repro_main.c (own main + counters)
@@ -1150,7 +1279,7 @@ $(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) $(BUILD_DIR)/.build-config |
$(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
$(BUILD_DIR)/prod_lsp_all.o: $(LSP_UNITY_DEPS) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
- $(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
+ $(CC) $(GRAMMAR_CFLAGS) $(TEST_COVERAGE_FLAGS) -c -o $@ $<
$(BUILD_DIR)/prod_preprocessor.o: $(CBM_DIR)/preprocessor.cpp $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CXX) $(CXXFLAGS_PROD) -w -I$(CBM_DIR)/vendored -c -o $@ $<
@@ -1188,7 +1317,7 @@ MAIN_SRC = src/main.c
# for trap debugging -- silently re-run the previously instrumented binary,
# which during this bisect produced a "crashes without sanitizers too" result
# that was pure artifact and sent the investigation down the wrong path.
-BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE)
+BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE)|TEST_COVERAGE=$(TEST_COVERAGE)
.PHONY: build-config-check
build-config-check:
$(BUILD_DIR)/.build-config: build-config-check | $(BUILD_DIR)
@@ -1216,8 +1345,11 @@ PROD_SRCS_WITH_ASSETS = $(subst src/ui/embedded_stub.c,src/ui/embedded_assets.c,
# Embedded asset object files (generated by embed script)
EMBED_OBJS = $(wildcard $(BUILD_DIR)/embedded/embed_*.o)
+# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: the UI's dev dependencies include
+# playwright for its local browser proofs; a plain `npm ci` would otherwise
+# download browsers, which no build or gate needs.
frontend:
- cd graph-ui && npm ci && npm run build
+ cd graph-ui && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci && npm run build
embed: frontend
scripts/embed-frontend.sh graph-ui/dist $(BUILD_DIR)/embedded
@@ -1384,6 +1516,84 @@ test-lsan:
cd $(CURDIR) && ASAN_OPTIONS="detect_leaks=1:halt_on_error=1" \
./build/lsan/test-runner $(LSAN_SUITES)
+# ── Per-test coverage build (test-impact map) ───────────────────
+# The instrumented runner and product binary (TEST_COVERAGE=1 above), in their
+# own build directory. Homebrew LLVM rather than Apple clang: the raw profile
+# format is versioned with the compiler, so llvm-profdata and llvm-cov have to
+# come from the toolchain that built the binary.
+# LLVM_PROFILE_FILE=/dev/null CBM_TEST_COVERAGE_DIR= \
+# build/cov/test-runner [suite...]
+# leaves one profile set per test (tests/test_main.c). The runner names every
+# profile itself, but the profile runtime creates its default file before main
+# runs: started without LLVM_PROFILE_FILE it leaves an empty default.profraw in
+# the working directory.
+# No C suite runs the product binary -- they re-exec the runner itself. The
+# shell process regressions do, and they find the instrumented one the way they
+# find any other: CBM_TEST_BINARY=build/cov/codebase-memory-mcp, with
+# LLVM_PROFILE_FILE set by whoever collects the profiles.
+COV_LLVM_BIN ?= /opt/homebrew/opt/llvm/bin
+COV_BUILD_DIR ?= build/cov
+COV_MAKE = $(MAKE) -f Makefile.cbm CC=$(COV_LLVM_BIN)/clang CXX=$(COV_LLVM_BIN)/clang++ \
+ BUILD_DIR=$(COV_BUILD_DIR) SANITIZE= TEST_COVERAGE=1
+test-runner-cov:
+ $(COV_MAKE) $(COV_BUILD_DIR)/test-runner
+cbm-cov:
+ $(COV_MAKE) $(COV_BUILD_DIR)/codebase-memory-mcp TEST_SEAMS=1
+
+# Genuine LLVM write failures must fail the parent coverage run.
+test-coverage-flush: test-runner-cov
+ @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-flush.XXXXXX") && \
+ printf 'Coverage flush evidence: %s/proof\n' "$$evidence_dir" && \
+ python3 tests/test_coverage_flush.py \
+ --runner "$(COV_BUILD_DIR)/test-runner" \
+ --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
+ --output-dir "$$evidence_dir/proof"
+
+.PHONY: test-coverage-children
+# Genuine child profile/marker and setup-reducer controls; evidence is retained.
+test-coverage-children: test-runner-cov
+ @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-children.XXXXXX") && \
+ printf 'Coverage child evidence: %s/proof\n' "$$evidence_dir" && \
+ python3 tests/test_coverage_children.py \
+ --runner "$(COV_BUILD_DIR)/test-runner" \
+ --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
+ --reducer scripts/test-impact/coverage-map.py \
+ --output-dir "$$evidence_dir/proof"
+
+.PHONY: test-coverage-parent-failure
+# Positive default plus genuine owned EMFILE; all evidence is retained.
+test-coverage-parent-failure: test-runner-cov
+ @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-parent-failure.XXXXXX") && \
+ printf 'Coverage parent failure evidence: %s/proof\n' "$$evidence_dir" && \
+ python3 tests/test_coverage_parent_failure.py \
+ --runner "$(COV_BUILD_DIR)/test-runner" \
+ --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
+ --reducer scripts/test-impact/coverage-map.py \
+ --output-dir "$$evidence_dir/proof"
+
+.PHONY: test-coverage-terminal
+# A readable stale terminal profile cannot hide a failed parent write.
+test-coverage-terminal: test-runner-cov
+ @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-terminal.XXXXXX") && \
+ printf 'Coverage terminal evidence: %s/proof\n' "$$evidence_dir" && \
+ python3 tests/test_coverage_terminal.py \
+ --runner "$(COV_BUILD_DIR)/test-runner" \
+ --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
+ --helper tests/test_coverage_children.py \
+ --reducer scripts/test-impact/coverage-map.py \
+ --output-dir "$$evidence_dir/proof"
+
+.PHONY: test-coverage-live-v2
+# Native full-identity transport remains explicitly unadmitted; evidence is retained.
+test-coverage-live-v2: test-runner-cov
+ @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-live-v2.XXXXXX") && \
+ printf 'Coverage live-v2 evidence: %s/proof\n' "$$evidence_dir" && \
+ PYTHONDONTWRITEBYTECODE=1 python3 tests/test_coverage_live_v2.py \
+ --collector scripts/test-impact/coverage-map.py \
+ --runner "$(COV_BUILD_DIR)/test-runner" \
+ --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
+ --output "$$evidence_dir/proof" $(if $(COV_LIVE_V2_CASE),--case "$(COV_LIVE_V2_CASE)")
+
# ── Coverage-guided fuzzing (libFuzzer) ─────────────────────────
# One binary, three targets (tests/fuzz/cbm_fuzz.c): the per-file extractor,
# the Cypher parser, the TOML/YAML/JSON config editors. ASan+UBSan as in the
@@ -1399,8 +1609,12 @@ fuzz:
$(MAKE) -f Makefile.cbm build/fuzz/cbm-fuzz BUILD_DIR=build/fuzz \
CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)"
+# -fsanitize-link-c++-runtime: the link runs through the C driver, which on
+# Linux leaves out UBSan's C++ runtime, but the C++ objects (preprocessor,
+# simplecpp) carry -fsanitize=vptr checks that need it
+# (__ubsan_vptr_type_cache, __ubsan_handle_dynamic_type_cache_miss).
$(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR)
- $(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -o $@ \
+ $(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -fsanitize-link-c++-runtime -o $@ \
$(FUZZ_SRC) $(PROD_SRCS) \
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
$(OBJS_VENDORED_TEST) \
diff --git a/README.md b/README.md
index f644775a7..590c9eef9 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,12 @@
-# codebase-memory-mcp
+# codebase-memory-mcp: local MCP server for code search, lower token usage and visualization
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[](LICENSE)
-[](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
-[](https://github.com/DeusData/codebase-memory-mcp)
+[](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/release.yml)
+[](#tests)
+[](#mcp-tools)
[](https://github.com/DeusData/codebase-memory-mcp)
-[](#hybrid-lsp)
+[](#hybrid-lsp)
[](https://github.com/DeusData/codebase-memory-mcp)
[](https://github.com/DeusData/codebase-memory-mcp)
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
@@ -14,42 +15,70 @@
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[](https://arxiv.org/abs/2603.27277)
-**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a native executable with a small verified runtime-asset set for macOS, Linux, and Windows — download, run `install`, done.
+**Search your code, follow call graphs, and explore software architecture in your browser.** codebase-memory-mcp is a local [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that gives Claude Code, Codex, and other coding agents a persistent code knowledge graph. It indexes functions, classes, imports, HTTP routes, and their relationships across [162 languages](#language-support). [Hybrid LSP](#hybrid-lsp) adds semantic type resolution for **Python, TypeScript, JavaScript, JSX/TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl**.
-High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 162 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 17 MCP tools. No language runtime, hosted service, or API key. Plug and play across 45 supported automatic/conditional client surfaces.
+**About 120× fewer tokens** in the project's reported five-query comparison: ~3,400 vs ~412,000 with file-by-file exploration. Published backend benchmarks also report **Linux kernel indexing in ~3 minutes** (28M lines of code) and **Cypher relationship queries under 1 ms** on an Apple M3 Pro. [Benchmark details](#performance).
-> **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration.
+**CBM Lens Beta helps you understand unfamiliar code and stay in control as it grows.** Use it to get up to speed in a new codebase, tackle rescue engineering on an inherited project, or make sense of 50,000 lines of AI-generated code from a single day. Its IDE-style source reader, local code chat, 3D architecture map, and Galaxy graph let you see how the code fits together and follow dependencies and call paths back to the source. Use that context to review changes, investigate fragile areas, and keep code quality high. Architecture decisions stay alongside the project's index.
-> **Security & Trust** — This tool reads your codebase and writes to your agent configuration files. That is what it is designed to do. If you prefer to audit before running, the [full source is here](https://github.com/DeusData/codebase-memory-mcp). For each release product, three behaviourally identical executable candidates (unstripped, debug-stripped, stripped) are submitted to VirusTotal before testing; the selected candidate is then packaged with its SHA-256 unchanged. Release notes link every measured candidate result. Publication permits only the narrowly documented single-Microsoft `!ml` tolerance in [SECURITY.md](SECURITY.md#our-release-policy). All processing happens 100% locally; your code never leaves your machine. Found a security issue? We want to know — see [SECURITY.md](SECURITY.md). Security is Priority #1 for us.
+The native C backend exposes 17 MCP tools and supports 45 automatic or conditional client integrations. Indexing and graph queries need no hosted service or API key.
+
+> **CBM Lens Beta is new on `main`:** it arrived with [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068) and is not part of the current v0.11.0 release, whose installer still ships the previous graph UI. To run the interface shown below now, [build from source](#build-from-source).
+
+[Quick start](#quick-start) · [Explore CBM Lens](#cbm-lens-beta-code-visualization-and-source-navigation) · [Chat with your codebase locally](#chat-with-your-codebase-locally) · [MCP tools](#mcp-tools) · [Build from source](#build-from-source)
+
+> **Research:** The [preprint](https://arxiv.org/abs/2603.27277) compares agents across 31 repositories, reporting **10× lower token use** and **2.1× fewer tool calls**, with answer-quality scores of 83% for CBM versus 92% for file-by-file exploration. These backend results are separate from the five-query comparison above and from CBM Lens.
+
+> **Local processing:** Source analysis and graph storage run on your machine. CBM Lens can optionally download a model for browser AI after you enable it; inference stays on the device. The installer writes agent configuration for the clients you select. See [Security](#security) for release verification and [SECURITY.md](SECURITY.md) for reporting issues.
+
+**Architecture overview.** Folders become nested platforms, files and symbols become selectable blocks, and arrows show indexed relationships. The recording zooms into the map of this repository, selects `src/pipeline`, opens the area, selects `artifact.c` and opens its source. Height can show source size; hotspot gravity reflects static fan-in. [Still image](docs/images/lens-architecture.png).
-
-
- Built-in 3D graph visualization — explore your knowledge graph at localhost:9749
+
+**Galaxy.** Explore the code knowledge graph in 3D. The recording searches `cbm_pipeline_run`, follows its outgoing calls one layer further, switches to the hierarchy and shows the path to `cbm_store_check_integrity`. Edge pulses show relationship direction; they do not represent live execution. [Current still: two dependency layers around `cbm_max_file_bytes`](docs/images/lens-galaxy.jpg).
+
+
+
+
+
+**Explore.** Browse files in an IDE-style workspace, read syntax-highlighted source in Monaco, and keep the code graph alongside it. File impact shows what depends on the open file, and Code details shows the callers and calls of the symbol under the caret. Select code to [ask the local chat about it](#chat-with-your-codebase-locally).
+
+
+
+
+More views: system structure and behavior
+
+
System structure groups symbols into components and lets you inspect their dependencies.
+
+
+
Behavior shows possible calls from a selected operation and the source evidence behind them.
+
+
+
+
+These captures show CBM Lens Beta on 12–13 October 2026, using public source from this repository. See [capture details](docs/images/README.md) for each capture's build, indexed scope and recording setup.
+
## Why codebase-memory-mcp
-- **Extreme indexing speed** — Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
-- **Plug and play** — native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
-- **162 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
-- **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles.
-- **45 supported automatic/conditional client surfaces** — `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
-- **Built-in graph visualization** — 3D interactive UI at `localhost:9749`, served from the binary itself.
-- **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources.
-- **17 MCP tools** — search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
+- **Indexing performance**: Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
+- **Plug and play**: native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
+- **162 languages**: vendored tree-sitter grammars compiled into the binary. No separate parser installation.
+- **About 120× fewer tokens in the reported five-query comparison**: ~3,400 tokens vs ~412,000 via file-by-file search. See [Performance](#performance) for the scope and sources.
+- **45 supported automatic/conditional client surfaces**: `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
+- **CBM Lens Beta**: read source, chat about selected code with a local model, explore architecture and call graphs, inspect coverage gaps, and edit architecture decision records in your browser.
+- **Infrastructure-as-code indexing**: Dockerfiles, container images, docker compose, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds and compose services, `Image` nodes joining the images that are built and run, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources; a Service walks to the program its container runs.
+- **17 MCP tools**: search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
## Quick Start
-**One-line install** (macOS / Linux):
+**Install the latest release** (macOS / Linux). For CBM Lens Beta shown above, [build from source](#build-from-source) until the next release.
```bash
curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
```
-With graph visualization UI:
-```bash
-curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
-```
+The released graph UI is included in the standard installer.
**Windows** (PowerShell):
```powershell
@@ -72,14 +101,14 @@ Unblock-File .\install.ps1
Options: `--skip-config` (binary only, no agent setup), `--dir=` (custom location).
> **Antivirus note:** Microsoft Defender may flag a release binary as
-> `Trojan:Script/Wacatac.B!ml`. This is a known false positive — typically 61 of
+> `Trojan:Script/Wacatac.B!ml`. This is a known false positive; typically 61 of
> ~62 engines return clean, and the same detection family hits `gh`, llama.cpp,
> Godot and Microsoft's own Go toolchain. See
> [Antivirus False Positives](SECURITY.md#antivirus-false-positives) for the
> evidence, how to verify the artifacts yourself, and how to report it if you
> think we are wrong.
-Restart your coding agent. Say **"Index this project"** — done.
+Restart your coding agent. Say **"Index this project"**: done.
Manual install
@@ -104,14 +133,14 @@ Restart your coding agent. Say **"Index this project"** — done.
3. **Restart** your coding agent.
-The `install` command automatically strips macOS quarantine attributes and ad-hoc signs the binary — no manual `xattr`/`codesign` needed.
+The `install` command automatically strips macOS quarantine attributes and ad-hoc signs the binary; no manual `xattr`/`codesign` needed.
The `install` command auto-detects installed coding agents and configures their documented MCP entries plus durable instructions, skills, and lifecycle hooks where supported.
### Session Coordination Daemon
-CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and the final session shuts it down. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues.
+CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and a session-managed daemon stops after the final session. A daemon started with `daemon start` remains running until stopped. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues.
The detached daemon does not depend on an MCP frontend's stderr. It keeps owner-only durable records under the canonical `${CBM_CACHE_DIR}/logs` directory (default `~/.cache/codebase-memory-mcp/logs`):
@@ -127,19 +156,87 @@ All active CBM processes must run the exact same version, executable build, coor
The native `install`, `update`, and `uninstall` commands are the deliberate exception to that conflict rule. Download, verification, and private same-filesystem staging happen first so a bad candidate never disrupts active work. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in `${CBM_CACHE_DIR}/logs/activation-events.ndjson`, and a successful command tells you to restart open coding-agent sessions so they launch the activated build.
-Package-manager setup (npm, PyPI, or Go) verifies and publishes a coherent private cached runtime set. Sidecars are replaced before the executable with per-file atomic renames; an interrupted multi-file publication is detected and repaired on the next launch rather than being described as one crash-atomic filesystem transaction. It does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee.
+Package-manager setup (npm, PyPI, or Go) verifies and caches the native executable for that package. It does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee.
-The ordinary `cli` mode runs one command locally, but it is not disconnected from the daemon above: it connects to the shared coordination daemon — starting one if none is already running — for that same admission barrier plus per-project locks on graph mutations. Its connection is a `cli_session`: it never registers with the background watcher and never starts a UI, and it holds the admission lease only for the command's lifetime. If the command's own connection is what started the daemon, that daemon exits again once the command closes and no other session is attached; if a daemon was already running, the command simply joins and leaves it exactly as found. While the command is running, a temporary monitor separately lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
+The ordinary `cli` mode runs one command locally, but it is not disconnected from the daemon above: it connects to the shared coordination daemon (starting one if none is already running) for that same admission barrier plus per-project locks on graph mutations. Its connection is a `cli_session`: it never registers with the background watcher and never starts a UI, and it holds the admission lease only for the command's lifetime. If the command's own connection is what started the daemon, that daemon exits again once the command closes and no other session is attached; if a daemon was already running, the command simply joins and leaves it exactly as found. While the command is running, a temporary monitor separately lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
-### Graph Visualization UI
+### CBM Lens Beta: code visualization and source navigation
-The graph UI is built into the binary — every install on every channel has it. Then run it:
+CBM Lens Beta is the browser interface built into the binary. It builds on [CodeAtlasWeb](graph-ui/README.md) by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) (design and tracking in #1964). It runs on loopback; the only request that leaves your machine is the optional model download from Hugging Face, and only after you start it. Each panel names where its facts come from, and what the index did not record is shown as a gap. Start the local server:
```bash
-codebase-memory-mcp --ui=true --port=9749
+./build/c/codebase-memory-mcp daemon start --open --port=9749
```
-Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers.
+The browser opens at `http://127.0.0.1:9749`. A permanent daemon keeps the UI available after the command exits. Use `daemon status` to inspect it and `daemon stop` when you are finished. For an MCP session, `--ui=true --port=9749` enables the same shared UI. Active clients must use the same executable build and cache directory.
+
+**Open your first repository**
+
+1. Open the project menu and choose **Add project index**. You can also use `Alt+P`.
+2. Choose a local repository folder and name the index. Files stay in place.
+3. Start indexing and follow its reported status. You can close the dialog while it runs and reopen its activity from the project menu.
+4. Open the indexed project. Start with Architecture for an overview or Explore to read a file.
+
+To bookmark a view, use `?project=&workspace=architecture`. Other workspace values are `explore`, `galaxy`, `adr`, `coverage`, and `system`.
+
+| Workspace | What you can do |
+|-----------|-----------------|
+| **Explore** | Navigate the indexed file tree and read source in Monaco. File impact shows what depends on the open file; Code details shows the callers, calls, references and related tests of the symbol under the caret. |
+| **Galaxy** | Search and focus graph nodes, load their neighbors layer by layer, switch to hierarchy, follow a path, and open source. Back and Forward return to earlier views. The coverage shadow marks known gaps around the loaded graph. |
+| **Architecture** | Explore the folder map, entry points, routes, hotspots, system structure, and behavior paths. Select a node or connection to inspect its evidence. |
+| **ADR** | Read and edit the project's architecture decision record. Save explicitly; the document is stored with the index and shared with `manage_adr`. |
+| **Coverage** | Check indexed, partially parsed, skipped, and excluded paths, together with available freshness information. |
+| **System** | Inspect daemon activity, indexes, and logs. Filter logs by project, severity, or text. |
+
+**Architecture views**
+
+- **Overview:** move through source folders and files on a 3D map. Choose source-size or uniform height, language or kind colors, and relationship filters. Switch to Entry points to follow a static call graph from a starting symbol.
+- **Routes:** switch between the service map and endpoint connections. Deployment metadata and indexed route evidence are shown separately; unresolved registrations remain visible.
+- **Hotspots:** inspect symbols using measured signals such as fan-in and complexity. Gravity wells help locate frequently referenced areas. They are not a code-quality score.
+- **System structure:** inspect component dependencies, expand groups, isolate cycles, and follow connections back to source evidence.
+- **Behavior:** choose a source and a reachable destination to inspect static call paths and their source statements. These paths show possible relationships, not a recorded execution order. Runtime observations require separately ingested traces.
+
+Drag a 3D view to orbit, scroll to zoom, and select a node or edge to inspect it. Edge colors distinguish relationships, and pulses indicate their direction. Reduced-motion preferences pause the pulses. Large views show their limits and omitted counts. A missing edge can reflect an indexing or display limit, so check Coverage and the source before drawing conclusions.
+
+#### Chat with your codebase locally
+
+Open **Chat** with the panel button at the top right to ask about the code you are looking at. In Explore it uses the open file or the exact text you have selected; in Galaxy it uses the selected node and its loaded neighbors. You can also attach graph evidence through **Add context**.
+
+1. Click the robot icon at the top right to open the agent configuration.
+2. Choose a local model. The default Qwen2.5 Coder 0.5B downloads about 567 MB; Qwen3 0.6B, LFM2.5 1.2B and Qwen3.5 2B are larger options. All need WebGPU with `shader-f16` support.
+3. Click **Download & load**. Model files come from Hugging Face; inference runs in your browser, with no hosted chat API or API key. A model that is already in the browser cache loads with **Load model (cached, no download)**.
+4. Ask a question. The composer shows the file or selection that will accompany it. Select a smaller range when you want to focus the answer.
+
+Questions about callers and callees, such as "Who calls cbm_pipeline_run?", are answered from the loaded graph without the model, and the answer says so. When you select a symbol, the chat lists its indexed facts and the lines of its code, and the model adds one sentence; that sentence is left out when it claims something the source does not show. Text written by the model is labelled as such.
+
+The recording shows a question about callers answered from the indexed graph, next to the automatic explanation of the selected symbol. [Still image](docs/images/lens-chat.png).
+
+
A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.
+
A background watcher detects changes in the project your MCP session is working in and re-indexes it incrementally. No manual reindex after editing files there.
Team-shared graph artifact
@@ -805,7 +805,7 @@
Do I need Docker or a runtime?
The native install needs no language runtime. It is a native executable with authenticated release-owned
- assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64); npm, PyPI, and Go wrapper
+ assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64); npm, PyPI, and Go wrapper
installations naturally use their package runtime.
How does it stay up to date as I edit code?
diff --git a/docs/llms.txt b/docs/llms.txt
index f3c23970b..0d0c5a49c 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -15,7 +15,7 @@
- Supported agents: 45 automatic/conditional client surfaces (39 automatically detected + 6 conditional/explicit): Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Continue / cn, Visual Studio, TRAE, Roo Code, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob IDE, IBM Bob Shell, Pochi, Pi, Oh My Pi (omp), and Sourcegraph Cody.
- Agent profiles: documented custom-agent formats receive Scout (fast/provisional), Verify (default/task-directed), and Auditor (bounded/full verification) definitions. Every direct tier checks exact path/scope coverage with check_index_coverage and falls back to source for flagged gaps; unsafe child-MCP formats use explicit parent handoff. Kiro and Junie use positive-allowlist Scout/Analysis server profiles (7/11 tools); Qoder combines named-server selection with exact tier-specific MCP tool IDs, while Factory uses exact registered IDs without additive whole-server exposure. Foreign Junie aliases are preserved and force parent handoff.
- Performance: Linux kernel (28M LOC, 75K files) full index in 3 minutes → 4.81M nodes, 7.72M edges; Cypher queries in under 1ms.
-- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, and `go install`.
+- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, AUR, and `go install`.
## Links
diff --git a/flake.nix b/flake.nix
index 5a0afbb98..06519160a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -63,7 +63,7 @@
packages = forAllSystems (pkgs: rec {
default = pkgs.stdenv.mkDerivation {
pname = "codebase-memory-mcp";
- version = "0.11.0";
+ version = "0.12.1";
src = ./.;
diff --git a/graph-ui/.gitignore b/graph-ui/.gitignore
new file mode 100644
index 000000000..fc184ba03
--- /dev/null
+++ b/graph-ui/.gitignore
@@ -0,0 +1,60 @@
+.DS_Store
+cbm/
+node_modules/
+dist/
+*.log
+.vite/
+*.tsbuildinfo
+
+# Die lokalen Modelle und die gebaute Inferenz-Runtime: zusammen ueber 11 GB
+# und beide reproduzierbar aus Pruefsummen und Baubefehl. Was committet wird,
+# ist die Herkunft und nicht das Gewicht.
+#
+# Die vier Zeilen je Verzeichnis sind kein Umweg, sondern das, was git
+# verlangt: ein ausgeschlossenes VERZEICHNIS wird gar nicht erst betreten, also
+# koennte eine Ausnahme darin nie greifen (git-scm, gitignore(5): "It is not
+# possible to re-include a file if a parent directory of that file is
+# excluded"). Die Verzeichnisregel steht trotzdem zuerst, weil sie die Absicht
+# nennt; die naechste hebt sie fuer das Verzeichnis selbst wieder auf, die
+# dritte schliesst seinen Inhalt aus, und erst die vierte holt die eine Datei
+# zurueck, die committet gehoert.
+models/
+!models/
+models/*
+!models/SHA256SUMS
+
+vendor/
+!vendor/
+vendor/*
+!vendor/llama/
+vendor/llama/*
+!vendor/llama/HERKUNFT.md
+
+# Die Laufspuren des Sidecars. Die PID-Datei gehoert dem laufenden Prozess,
+# das Log ist ohnehin schon durch *.log erfasst.
+llm/.pid
+
+# Der Node-Bau des Context-Compilers fuer die Eval. Er wird bei jedem Lauf neu
+# erzeugt (tools/lib/compiler-bundle.mjs); ein committetes Bundle waere eine
+# zweite, alternde Fassung der Dateien daneben.
+.eval-build/
+
+# Die Ereignisdatei der Agenten (W11a). Sie enthaelt keine Dateiinhalte, aber
+# sie verraet, an welchen Dateien jemand gearbeitet und welche Befehle er
+# gefahren hat. Das ist die Arbeitsspur einer Person und gehoert nicht in ein
+# Repository; was committet ist, ist die AUFZEICHNUNG des Beweislaufs unter
+# fixtures/agent-events/, und deren Herkunft steht dort daneben.
+.atlas-trace/
+events.jsonl
+events.jsonl.seq
+
+# Local visual captures and development reports; JSON/JSONL fixtures stay tracked.
+/verification/**/*.png
+/verification/**/*.jpg
+/verification/**/*.jpeg
+/verification/**/*.gif
+/verification/**/*.webp
+/verification/**/*.svg
+/verification/**/*.webm
+/verification/**/*.mp4
+/verification/**/*.md
diff --git a/graph-ui/@/components/ui/badge.tsx b/graph-ui/@/components/ui/badge.tsx
deleted file mode 100644
index 6eb2a057a..000000000
--- a/graph-ui/@/components/ui/badge.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { Slot } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-
-const badgeVariants = cva(
- "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
- secondary:
- "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
- destructive:
- "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
- outline:
- "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- link: "text-primary underline-offset-4 [a&]:hover:underline",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-)
-
-function Badge({
- className,
- variant = "default",
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> &
- VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : "span"
-
- return (
-
- )
-}
-
-export { Badge, badgeVariants }
diff --git a/graph-ui/@/components/ui/button.tsx b/graph-ui/@/components/ui/button.tsx
deleted file mode 100644
index 4d38506ce..000000000
--- a/graph-ui/@/components/ui/button.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { Slot } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-
-const buttonVariants = cva(
- "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
- destructive:
- "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
- outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80",
- ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
- xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
- icon: "size-9",
- "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
- "icon-sm": "size-8",
- "icon-lg": "size-10",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-function Button({
- className,
- variant = "default",
- size = "default",
- asChild = false,
- ...props
-}: React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean
- }) {
- const Comp = asChild ? Slot.Root : "button"
-
- return (
-
- )
-}
-
-export { Button, buttonVariants }
diff --git a/graph-ui/@/components/ui/card.tsx b/graph-ui/@/components/ui/card.tsx
deleted file mode 100644
index acf57dc5a..000000000
--- a/graph-ui/@/components/ui/card.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import * as React from "react"
-
-import { cn } from "@/lib/utils"
-
-function Card({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardAction({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-export {
- Card,
- CardHeader,
- CardFooter,
- CardTitle,
- CardAction,
- CardDescription,
- CardContent,
-}
diff --git a/graph-ui/@/components/ui/checkbox.tsx b/graph-ui/@/components/ui/checkbox.tsx
deleted file mode 100644
index f5a7e433f..000000000
--- a/graph-ui/@/components/ui/checkbox.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { CheckIcon } from "lucide-react"
-import { Checkbox as CheckboxPrimitive } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-
-function Checkbox({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
- )
-}
-
-export { Checkbox }
diff --git a/graph-ui/@/components/ui/input.tsx b/graph-ui/@/components/ui/input.tsx
deleted file mode 100644
index f1124aea1..000000000
--- a/graph-ui/@/components/ui/input.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from "react"
-
-import { cn } from "@/lib/utils"
-
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
- return (
-
- )
-}
-
-export { Input }
diff --git a/graph-ui/@/components/ui/scroll-area.tsx b/graph-ui/@/components/ui/scroll-area.tsx
deleted file mode 100644
index e38a492f2..000000000
--- a/graph-ui/@/components/ui/scroll-area.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import * as React from "react"
-import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-
-function ScrollArea({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
-
-
- )
-}
-
-function ScrollBar({
- className,
- orientation = "vertical",
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-export { ScrollArea, ScrollBar }
diff --git a/graph-ui/@/components/ui/separator.tsx b/graph-ui/@/components/ui/separator.tsx
deleted file mode 100644
index db91e97ad..000000000
--- a/graph-ui/@/components/ui/separator.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as React from "react"
-import { Separator as SeparatorPrimitive } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-
-function Separator({
- className,
- orientation = "horizontal",
- decorative = true,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export { Separator }
diff --git a/graph-ui/CONFIGURATION.md b/graph-ui/CONFIGURATION.md
new file mode 100644
index 000000000..22afda714
--- /dev/null
+++ b/graph-ui/CONFIGURATION.md
@@ -0,0 +1,73 @@
+# Frontend configuration
+
+Open **System**, then **Configuration**. Settings are grouped by indexing,
+resources, server, diagnostics, and browser. The **External inputs** reference
+covers repository files, installation, launch flags, integration paths, and
+development settings whose owner is outside the running daemon.
+
+## Daemon overrides
+
+Daemon edits are staged until **Save changes**. A batch is validated and committed
+atomically to `_config.db` in the CBM cache directory. Invalid keys or values reject
+the whole batch. Each row exposes its source, default, saved override, current
+process value, and application timing. **Reset** removes the saved override; it
+does not overwrite the inherited environment or replace it with a hardcoded default.
+Legacy environment values outside the registry's canonical format are shown as
+runtime-interpreted inputs, rather than guessed effective values. Saving an override
+requires a validated value.
+
+Supported environment overrides are loaded once when a process starts, ahead of
+the inherited environment and built-in defaults. They do not mutate or export the
+process environment. Restart CBM to apply changes throughout the installation;
+newly launched index workers can load saved values before the existing daemon is
+restarted. Explicit command flags, per-index options, and supervisor recovery caps
+retain their documented precedence.
+
+The existing `auto_index`, `auto_index_limit`, `auto_watch`, `watcher_enabled`, and
+`ui-lang` settings share their canonical SQLite values with CLI configuration.
+UI enablement and port overrides use the same transactional store, with legacy
+UI `config.json` as a fallback. Explicit CLI/UI saves update that store too.
+Concurrent edits use a revision check; a stale save fails without changing data.
+
+Launch locations, filesystem access grants, arbitrary output destinations,
+installer settings, test injection, and supervisor controls are references rather
+than editable process overrides. Platform-specific or unlinked functionality is
+marked unavailable. No generic environment editor or secret enumeration is exposed.
+
+## Browser preferences
+
+Browser changes apply immediately. Graph display and edge motion use their existing
+preference stores. Galaxy budgets, coverage shadow, and architecture appearance
+share a versioned, per-project browser store with the controls in those views.
+Changing these values preserves the current selection. If storage is unavailable,
+preferences remain usable for the current session.
+
+The top-bar agent status opens separate local model controls. Opening configuration never
+downloads weights or starts a model. Repository extension mappings and ignore files
+remain repository-owned and take effect on subsequent indexing.
+
+## Local HTTP API
+
+`GET /api/config` returns `{ revision, settings }`. Each setting declares its type,
+validation bounds or enum choices, lifecycle, provenance, and editability.
+The registry lives in `src/cli/runtime_settings.c`; consumer locations are included
+in its metadata. Browser response validation lives in `src/settings/config-model.ts`.
+
+`POST /api/config` accepts JSON with the revision just read and a nonempty changes
+object. Values are canonical strings; `null` removes an override:
+
+```json
+{
+ "revision": "12",
+ "changes": {
+ "CBM_WORKERS": "4",
+ "CBM_SEMANTIC_ENABLED": "false",
+ "CBM_SQLITE_MMAP_SIZE": null
+ }
+}
+```
+
+Success returns the updated snapshot. Invalid batches return 400, conflicting
+revisions return 409, and storage failures return 500. Existing loopback Host,
+same-origin, and JSON-content checks protect the endpoint. Frontend drafts survive
+errors so users can inspect the conflict before reloading or discarding them.
diff --git a/graph-ui/README.md b/graph-ui/README.md
new file mode 100644
index 000000000..276dd51f8
--- /dev/null
+++ b/graph-ui/README.md
@@ -0,0 +1,132 @@
+# CBM Lens Beta
+
+CBM Lens is the local exploration frontend served by `codebase-memory-mcp --ui=true`.
+It combines source, the indexed graph and optional browser inference. Source files
+are read-only; project architecture decisions can be edited in the ADR workspace.
+The frontend was contributed by Bernhard Jackiewicz as CodeAtlasWeb.
+
+## Explore a project
+
+- **Explore** follows the current file or marked source. Its graph requests direct
+ dependencies separately from the capped repository layout. File-level impact
+ summaries use graph and local Git evidence, with details available on demand.
+- **Galaxy** searches nodes, files and folders. Select a scope and expand incoming
+ or outgoing relationships by depth and edge type. Overview budgets limit drawing;
+ a capped layout does not establish a complete neighborhood. Bounded caches reuse
+ positions and verified adjacency, and fresh index generations invalidate evidence.
+- **Architecture** presents the repository map, routes, hotspots, system structure
+ and behavior. Connectivity clusters are not proof of architectural boundaries.
+ Moving arrows show edge direction, not observed execution. Reduced-motion settings
+ retain stationary direction markers.
+- **Coverage** explains indexing gaps. **System** contains daemon status, indexes,
+ diagnostics and MCP configuration. See [configuration and precedence](CONFIGURATION.md).
+- **ADR** stores one editable Markdown decision document per project through
+ `GET/POST /api/adr`, shared with `manage_adr`. It does not edit repository files.
+
+Use **Add project index** in the project dropdown (or Alt+P) to choose a local
+repository, name its index and follow daemon-reported progress. Files are indexed
+in place, not uploaded. Closing the dialog does not cancel indexing.
+
+ADR drafts stay in this tab's session storage. Before saving, the UI compares the
+server document with the editing baseline and retains conflicting drafts. This is
+not an atomic document lock. Saves are limited to 8,000 UTF-8 bytes and a 16 KiB
+serialized request. The examples in `fixtures/adr/` are explicitly synthetic and
+are never inserted into real projects automatically.
+
+## Local agent
+
+The top-bar agent status opens model configuration; the adjacent chat control folds
+or unfolds the conversation. Model downloads require explicit activation. The chat
+uses browser-local inference; the selected source is not sent to a remote model.
+Model provenance and license limitations are in [THIRD_PARTY.md](THIRD_PARTY.md).
+
+Explorer provides the exact marked text, or the loaded file when nothing is marked.
+A caret alone does not narrow the context. One replaceable source block precedes
+conversation history; each message captures the current selection. Navigation does
+not alter an in-flight request, and Retry uses its original snapshot. Missing or
+partial source is disclosed. Token counting uses the selected model's tokenizer;
+oversized requests are rejected rather than silently shortened.
+
+Automatic explanations respond to new selections independently of follow-up chat.
+Browser history is separated by project, retained for 24 hours, and can be cleared
+in Agent configuration.
+
+## Build and preview
+
+From the repository root:
+
+```sh
+make -f Makefile.cbm cbm-with-ui
+```
+
+This installs the locked frontend dependencies, builds `dist/` and embeds it in the
+native binary. For frontend development, run `npm ci` and `npm run dev` in this
+directory. Vite listens on 5173 and proxies `/api` and `/rpc` to the local server on
+9749. `npm run build` creates a production bundle.
+
+The standalone test preview in `tools/lib/static-proxy.mjs` snapshots `dist/` when
+it starts. Restart that preview after building and compare the served `index.html`
+asset name with `dist/index.html` before reviewing changes.
+
+The canonical frontend CI entry point is `scripts/ci/test-ui.sh` from the repository
+root. It runs unit tests, style and promise checks, acceptance checks and a production
+build. Browser smoke commands require a running server and Playwright Chromium;
+install that browser explicitly with `npx playwright install chromium` when needed.
+
+## Experimental agent activity
+
+Build with `VITE_EXPERIMENTAL_AGENTS=true` to expose recorded tool activity, hook
+setup and graph overlays. The value must be exactly `true`; URL parameters and saved
+preferences cannot enable it. Browser-local chat is independent of this flag.
+Activity remains off until enabled in the UI. It requires an event adapter and is
+not automatic observation of arbitrary coding agents.
+
+The supported adapter is `agents/hooks/atlas-trace.py`. **Connect your agent**
+downloads it and offers a project-specific install command. Review and run it locally:
+
+```sh
+python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root /absolute/repository --project INDEXED_NAME --daemon-url http://127.0.0.1:9749
+```
+
+The installer preserves unrelated settings and hooks, rejects conflicts, symlinks
+and malformed configuration, and writes only the selected repository's
+`.claude/settings.local.json` and `.claude/hooks/`.
+Start a new session in your configured client after installation. Other clients
+need their own adapter.
+
+For manual hook setup, set `ATLAS_PROJECT` to the indexed name, optionally
+`ATLAS_AGENT_NAME` and `ATLAS_DAEMON_URL`, and pass the tool event on stdin. The hook
+records tool, run, sequence, timestamp, path and explicit line ranges. It omits source
+contents, tool output and free-form commands, search patterns and descriptions. Paths
+can still be sensitive. Destinations must be local; proxies and redirects are refused.
+
+A transactional outbox at `~/.atlas-trace/events.jsonl.outbox.sqlite3` buffers up to
+10,000 pending events. `ATLAS_TRACE_FILE` changes the path prefix. Delivery resumes
+on the next hook or `python3 agents/hooks/atlas-trace.py --flush`; one flush sends up
+to 100 events from one project. The older JSONL/replay tools are fixture utilities.
+
+## Activity and diagnostic APIs
+
+`POST /api/agent-events` accepts `{project, events: [...]}` with at most 100 events.
+Each event needs `ts`, `agent`, `run`, integer `seq`, `phase: start|end` and `tool`.
+Optional fields include `path`, `lines`, `detail`, `source`, self-reported `intent`
+and explicit `replay`. Unknown fields are discarded. The UI distinguishes recorded
+facts from self-reports and leaves unmapped paths visible.
+
+The daemon stores activity in `/activity.db`, independently of replaceable
+project indexes. Monotonic cursors, `(project, run, seq)` identities and retired
+sequence floors prevent duplicate or expired events from reappearing. Retention is
+bounded to 10,000 events; expired history is incomplete. Poll with
+`GET /api/agent-events?project=NAME&after=CURSOR&limit=200` on the graph's origin.
+
+Daemon and frontend logs share the SQLite journal. `GET /api/logs` accepts `project`,
+`scope=unattributed`, `min_level`, literal `q`, `after` and `lines`; filters apply
+before counts and pagination. Unattributed records are not assigned to the selected
+project. Responses identify severity, source, receipt time, durable ID and database
+generation. A bounded memory fallback is explicitly marked `persistent:false`.
+
+`POST /api/ui-log` batches frontend errors and request failures. Failed journal writes
+return 503 for retry. `GET /api/ui-log` reads the same journal; `path` names
+`activity.db`, while `export_path` names the rotating compatibility JSONL export.
+Changing that export does not change HTTP history. These APIs publish nothing
+externally.
diff --git a/graph-ui/THIRD_PARTY.md b/graph-ui/THIRD_PARTY.md
new file mode 100644
index 000000000..eb4270362
--- /dev/null
+++ b/graph-ui/THIRD_PARTY.md
@@ -0,0 +1,149 @@
+# CBM Lens third-party notices
+
+This document records imported source, package licenses and optional model provenance.
+Preserve the copyright and license headers in individual source files. The npm
+lockfile records the exact installed dependency versions.
+
+## Galaxy source
+
+The initial scene was adapted from [DeusData/codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp),
+`feat/atlas-r1` at `bda46086`, on 2026-08-28. It is MIT licensed,
+Copyright (c) 2025 DeusData; the full license is in the repository's `LICENSE`.
+
+| Lens file | Original under `graph-ui/src` |
+| --- | --- |
+| `src/galaxy/GraphScene.tsx` | `components/GraphScene.tsx` |
+| `src/galaxy/NodeCloud.tsx` | `components/NodeCloud.tsx` |
+| `src/galaxy/EdgeLines.tsx` | `components/EdgeLines.tsx` |
+| `src/galaxy/NodeLabels.tsx` | `components/NodeLabels.tsx` |
+| `src/galaxy/HaloLayer.tsx` | `components/HaloLayer.tsx` |
+| `src/galaxy/types.ts` | `lib/types.ts` |
+| `src/galaxy/density.ts` | `lib/density.ts` |
+
+Adaptations include component-local canvas access, memoized scene measurements,
+frontend-specific props and types, and revised graph interaction/rendering.
+`NodeTooltipCard.tsx` is a separate implementation using Lens styling rather than
+the original Tailwind tooltip. File headers retain the initial import details;
+subsequent changes are recorded in Git history.
+
+## Packages
+
+| Package | Recorded version | License |
+| --- | --- | --- |
+| `three` | 0.183.2 | MIT |
+| `@react-three/fiber` | 9.5.0 | MIT |
+| `@react-three/drei` | 10.7.7 | MIT |
+| `@react-three/postprocessing` | 3.0.4 | MIT |
+| `postprocessing` | 6.38.3 | Zlib |
+| `three-stdlib` | 2.36.1 | MIT |
+| `@types/three` | 0.183.1 | MIT |
+| `@huggingface/transformers` | 4.2.0 | Apache-2.0 |
+| `onnxruntime-web` | 1.26.0-dev.20260416-b7804b056c | MIT |
+| `sharp` | 0.35.0 | Apache-2.0 |
+| `react-markdown` | 10.1.0 | MIT |
+| `remark-gfm` | 4.0.1 | MIT |
+
+These packages are used in their published form. `postprocessing` requires accurate
+origin attribution and identification of modified versions under its Zlib license.
+React, Vite, Vitest and Monaco use MIT; TypeScript and Playwright use Apache-2.0.
+`sharp` is a transitive Node dependency with a targeted override, not browser code.
+Full Markdown renderer license texts appear below.
+
+## CodeAtlasIDE provenance
+
+Provider, semantic IR, twin and semantic-search modules were ported from Bernhard
+Jackiewicz's CodeAtlasIDE project by the same author. Their source headers retain
+that origin. They are original contributions from Bernhard, not imports from an
+unrelated third party.
+
+The concept of deriving an entry tour from project structure was inspired by
+Understand-Anything (MIT). `src/tours/tour-generator.ts` contains original Kahn
+traversal, role labels, wording and cycle handling; no source was copied from that
+project. Its direct code origin is CodeAtlasIDE.
+
+## Optional browser models
+
+Transformers.js and ONNX Runtime assets are served locally. Weights are not bundled;
+downloading them requires an explicit browser action. Source inference stays in the
+browser. Model policy pins revisions and permitted files; it does not execute remote
+model scripts.
+
+The following metadata was recorded on 2026-09-08. Download sizes include tokenizer
+and configuration files and do not describe GPU memory requirements.
+
+| Model | Fixed revision | Download bytes | Recorded license |
+| --- | --- | --- | --- |
+| [onnx-community/Qwen2.5-Coder-0.5B-Instruct](https://huggingface.co/onnx-community/Qwen2.5-Coder-0.5B-Instruct) | `f0292f665fd307846ff3c318a91a1bc29d091492` | 566366194 | Apache-2.0 for the base model |
+| [onnx-community/Qwen3-0.6B-ONNX](https://huggingface.co/onnx-community/Qwen3-0.6B-ONNX) | `da1453100cf3ff33ef56d17983fc7a8648706db6` | 578917626 | Apache-2.0 |
+| [LiquidAI/LFM2.5-1.2B-Instruct-ONNX](https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-ONNX) | `10f72e70abf67ac0fd7ebf15bc5854726891d864` | 763763755 | Liquid AI license; review commercial terms above $10M annual revenue |
+| [onnx-community/Qwen3.5-2B-ONNX-OPT](https://huggingface.co/onnx-community/Qwen3.5-2B-ONNX-OPT) | `2ea7886f48b926aca97de8b0e041ffca7e3ebaa9` | 1402850762 | Apache-2.0 |
+
+All use `q4f16`. Qwen3.5 loads the text decoder and embeddings only, not vision
+files. The Qwen2.5 ONNX weight file is 554935833 bytes; its published LFS SHA-256 is
+`60c076ac0d3910881fe0cad75997e803459a5f71bb74bcbcd82407ef83db7ba0`.
+The converter's model card does not declare a separate license; its
+[official base model](https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct)
+is Apache-2.0.
+
+`src/browser-ai/model-policy.ts` retains complete file lists, published LFS hashes
+and allowed hosts, including `huggingface.co` and the recorded redirect host
+`us.aws.cdn.hf.co`. The loader does not recompute the published hashes.
+The browser context limit is 8192 tokens including 512 reserved output tokens;
+requests are counted with the selected template and tokenizer rather than silently
+truncated. Qwen3 and Qwen3.5 default to non-thinking mode. Runtime support is not a
+benchmark or a compatibility guarantee for every browser/GPU combination.
+
+## Markdown renderer licenses
+
+### react-markdown
+
+```text
+The MIT License (MIT)
+
+Copyright (c) Espen Hovlandsdal
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+### remark-gfm
+
+```text
+(The MIT License)
+
+Copyright (c) Titus Wormer
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
diff --git a/graph-ui/agents/hooks/atlas-trace-watch.mjs b/graph-ui/agents/hooks/atlas-trace-watch.mjs
new file mode 100644
index 000000000..879519aad
--- /dev/null
+++ b/graph-ui/agents/hooks/atlas-trace-watch.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+/**
+ * Die zweite Quelle: Schreibvorgaenge auf der Platte.
+ *
+ * Sie ist der Rueckfall fuer alles, was keine Werkzeug-Hooks kennt. Ein Agent,
+ * der mit einem Editor oder einem Skript arbeitet, ruft keinen Hook auf; was
+ * dabei trotzdem sichtbar ist, ist die Datei, die sich aendert.
+ *
+ * ## Was sie sieht, und was nicht
+ *
+ * Sie sieht **nur Schreibvorgaenge**. Kein Lesen, kein Suchen, kein Testlauf,
+ * kein Befehl. Genau so ist jedes Ereignis gekennzeichnet (`source: "fs"`), und
+ * das ist keine Formalie: wer spaeter eine Aufzeichnung ansieht, muss wissen,
+ * dass die Ruhe zwischen zwei Schreibvorgaengen nicht Ruhe bedeutet, sondern
+ * Arbeit, die hier nicht ankommt. Ein Protokoll, das seine eigene Blindheit
+ * verschweigt, ist schlimmer als keines.
+ *
+ * Sie schreibt **keine Dateiinhalte**, so wenig wie der Hook daneben: ein
+ * Ereignis nennt den Pfad und die Zeit, mehr nicht. Einen Zeilenbereich kann
+ * sie nicht nennen, denn ein Dateisystem meldet keine Zeilen; das Feld fehlt
+ * darum, statt eine Zahl zu erfinden.
+ *
+ * ## Aufruf
+ *
+ * node agents/hooks/atlas-trace-watch.mjs
+ *
+ * Wohin geschrieben wird, sagt ATLAS_TRACE_FILE; ohne die Variable ist es
+ * ~/.atlas-trace/events.jsonl. Wie der Agent heisst, sagt ATLAS_AGENT_NAME;
+ * ohne sie steht dort "file-writes", weil das die Wahrheit ueber diese Quelle
+ * ist: sie kennt keinen Agenten, sie kennt eine Datei, die sich geaendert hat.
+ *
+ * Kein Paket, nur node:fs.
+ */
+import { appendFileSync, mkdirSync, watch } from 'node:fs';
+import { dirname, join, relative } from 'node:path';
+import { homedir } from 'node:os';
+
+const ROOT = process.argv[2] ?? process.cwd();
+const OUT = process.env['ATLAS_TRACE_FILE']
+ ?? join(homedir(), '.atlas-trace', 'events.jsonl');
+const AGENT = process.env['ATLAS_AGENT_NAME'] ?? 'file-writes';
+
+/** Der Lauf. Acht Zeichen, wie die Kennung des Hooks daneben. */
+const RUN = String(Date.now()).slice(-8);
+
+/** Was nie interessiert: Bauwerk, Fremdcode, Beweisbilder, das Protokoll selbst. */
+const IGNORE = /(^|\/)(node_modules|\.git|dist|cbm|vendor|models|verification|coverage)(\/|$)/;
+
+/**
+ * Derselbe Pfad zweimal in dieser Zeitspanne ist ein Ereignis, nicht zwei.
+ *
+ * Ein Editor schreibt gern mehrfach (Datei anlegen, Inhalt schreiben, Rechte
+ * setzen), und ein Bild soll die Handlung zeigen und nicht die Schreibtechnik.
+ */
+const COALESCE_MS = 400;
+
+const lastSeen = new Map();
+let seq = 0;
+
+function record(path) {
+ const now = Date.now();
+ if (now - (lastSeen.get(path) ?? 0) < COALESCE_MS) {
+ return;
+ }
+ lastSeen.set(path, now);
+ seq += 1;
+ const line = {
+ ts: now,
+ agent: AGENT,
+ run: RUN,
+ seq,
+ phase: 'end',
+ tool: 'Write',
+ path,
+ source: 'fs',
+ detail: 'seen as a write on disk; reads, searches and test runs are invisible here',
+ };
+ try {
+ appendFileSync(OUT, JSON.stringify(line) + '\n', 'utf8');
+ process.stdout.write(`${new Date(now).toISOString()} ${path}\n`);
+ } catch {
+ /* Ein volles Dateisystem kostet das Protokoll, nicht die Arbeit. */
+ }
+}
+
+try {
+ mkdirSync(dirname(OUT), { recursive: true });
+} catch {
+ /* Steht es schon da, ist alles gut; steht es nicht, meldet der erste Schreibversuch es. */
+}
+
+watch(ROOT, { recursive: true }, (_event, name) => {
+ if (typeof name !== 'string' || IGNORE.test(name)) {
+ return;
+ }
+ record(relative('.', name) || name);
+});
+
+process.stdout.write(`watching ${ROOT}, appending to ${OUT}\n`);
diff --git a/graph-ui/agents/hooks/atlas-trace.py b/graph-ui/agents/hooks/atlas-trace.py
new file mode 100644
index 000000000..86c9cde4a
--- /dev/null
+++ b/graph-ui/agents/hooks/atlas-trace.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""Record tool metadata in a local outbox and POST it to the existing daemon.
+
+Configure ATLAS_PROJECT to the indexed project name and ATLAS_DAEMON_URL when
+using a nondefault daemon port. No bridge, listener or external transmission.
+Pending events survive downtime and retry on the next hook or --flush.
+The hook always returns zero so observing work cannot break the tool call.
+"""
+import json
+import os
+import sys
+import time
+import sqlite3
+import uuid
+import urllib.request
+import urllib.parse
+import argparse
+import pathlib
+import shlex
+import tempfile
+
+DEFAULT_FILE = os.path.join(os.path.expanduser('~'), '.atlas-trace', 'events.jsonl')
+
+def trace_file():
+ return os.environ.get('ATLAS_TRACE_FILE') or DEFAULT_FILE
+
+
+def open_outbox():
+ path = trace_file() + '.outbox.sqlite3'
+ os.makedirs(os.path.dirname(os.path.abspath(path)), mode=0o700, exist_ok=True)
+ db = sqlite3.connect(path, timeout=0.5)
+ os.chmod(path, 0o600)
+ db.executescript("""
+ PRAGMA journal_mode=WAL;
+ CREATE TABLE IF NOT EXISTS counters(run TEXT PRIMARY KEY, seq INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY, project TEXT NOT NULL,
+ payload TEXT NOT NULL);
+ """)
+ return db
+
+
+def enqueue(db, project, row):
+ # Counter allocation and enqueue are one SQLite transaction: parallel hooks
+ # cannot assign the same run/sequence pair and silently discard an event.
+ with db:
+ db.execute('BEGIN IMMEDIATE')
+ db.execute('INSERT INTO counters VALUES(?,1) ON CONFLICT(run) DO UPDATE SET seq=seq+1',
+ (row['run'],))
+ row['seq'] = db.execute('SELECT seq FROM counters WHERE run=?', (row['run'],)).fetchone()[0]
+ db.execute('INSERT INTO pending(project,payload) VALUES(?,?)',
+ (project, json.dumps(row, ensure_ascii=False)))
+ # A prolonged daemon outage cannot fill the disk without bound. Sequence
+ # gaps remain visible when older undelivered metadata expires.
+ db.execute('DELETE FROM pending WHERE id IN (SELECT id FROM pending ORDER BY id DESC LIMIT -1 OFFSET 10000)')
+
+
+def flush(db):
+ origin = os.environ.get('ATLAS_DAEMON_URL', 'http://127.0.0.1:9749')
+ parsed = urllib.parse.urlsplit(origin)
+ if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost')
+ or parsed.username or parsed.password or parsed.path not in ('', '/')
+ or parsed.query or parsed.fragment):
+ return # Never forward local metadata to an external endpoint.
+ first = db.execute('SELECT project FROM pending ORDER BY id LIMIT 1').fetchone()
+ if not first:
+ return
+ rows = db.execute('SELECT id,payload FROM pending WHERE project=? ORDER BY id LIMIT 100',
+ first).fetchall()
+ data = json.dumps({'project': first[0], 'events': [json.loads(row[1]) for row in rows]}).encode()
+ request = urllib.request.Request(origin.rstrip('/') + '/api/agent-events', data=data,
+ headers={'Content-Type': 'application/json'})
+ # No environment proxy, redirects, or second server; post only to loopback.
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ return None
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
+ with opener.open(request, timeout=0.75) as response:
+ result = json.load(response)
+ if response.status != 200 or result.get('accepted', 0) + result.get('duplicates', 0) != len(rows):
+ return
+ with db:
+ db.executemany('DELETE FROM pending WHERE id=?', [(row[0],) for row in rows])
+
+
+def line_span(file_path, args):
+ """Use an explicit requested source range, never infer it after an edit.
+
+ PostToolUse runs after mutation. Searching old_string could locate a different,
+ unchanged occurrence and falsely attribute that line to the observed edit.
+ """
+ offset = args.get('offset')
+ limit = args.get('limit')
+ if isinstance(offset, int) and not isinstance(offset, bool) and offset > 0:
+ if isinstance(limit, int) and not isinstance(limit, bool) and limit > 0:
+ return [offset, offset + limit - 1]
+ return [offset, offset]
+
+ return None
+
+
+def daemon_origin(value):
+ parsed = urllib.parse.urlsplit(value)
+ if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost')
+ or parsed.username or parsed.password or parsed.path not in ('', '/')
+ or parsed.query or parsed.fragment):
+ raise ValueError('The daemon URL must be a loopback HTTP origin.')
+ return value.rstrip('/')
+
+
+def install_claude(root, project, origin):
+ """Explicit, project-local install; preserve unrelated settings and hooks.
+
+ No package downloads or network requests. Refuse symlinks, malformed settings,
+ and conflicting older installations rather than replace user configuration.
+ """
+ root = pathlib.Path(root).resolve(strict=True)
+ if not root.is_dir() or not project or len(project.encode()) > 255:
+ raise ValueError('Choose the indexed repository root and exact project name.')
+ origin = daemon_origin(origin)
+ directory = root / '.claude'
+ hook_dir = directory / 'hooks'
+ settings = directory / 'settings.local.json'
+ target = hook_dir / 'cbm-atlas-trace.py'
+ if any(path.is_symlink() for path in (directory, hook_dir, settings, target)):
+ raise ValueError('Refusing symlinked hook or settings paths.')
+ original = settings.read_bytes() if settings.exists() else None
+ config = json.loads(original) if original is not None else {}
+ if not isinstance(config, dict):
+ raise ValueError('Existing settings must be a JSON object.')
+ hooks = config.setdefault('hooks', {})
+ if not isinstance(hooks, dict):
+ raise ValueError('Existing hooks must be an object.')
+ entries = hooks.setdefault('PostToolUse', [])
+ if not isinstance(entries, list):
+ raise ValueError('Existing PostToolUse hooks must be an array.')
+ command = ('ATLAS_PROJECT=' + shlex.quote(project) + ' ATLAS_AGENT_NAME=' + shlex.quote('Claude Code')
+ + ' ATLAS_DAEMON_URL=' + shlex.quote(origin)
+ + ' python3 ' + shlex.quote(str(target)))
+ proposed = {'matcher': '', 'hooks': [{'type': 'command', 'command': command, 'timeout': 3}]}
+ installed = proposed in entries
+ for entry in entries:
+ for handler in entry.get('hooks', []) if isinstance(entry, dict) else []:
+ if not isinstance(handler, dict):
+ continue
+ try:
+ words = shlex.split(str(handler.get('command', '')))
+ except ValueError:
+ words = []
+ references_target = any(word in (str(target), '.claude/hooks/cbm-atlas-trace.py',
+ './.claude/hooks/cbm-atlas-trace.py') for word in words)
+ if references_target and entry != proposed:
+ raise ValueError('An existing Atlas hook differs. Review its matcher, command and timeout before changing it.')
+ source = pathlib.Path(__file__).read_bytes()
+ if target.exists() and target.read_bytes() != source:
+ raise ValueError('An existing Atlas hook file differs. Review it before replacing it.')
+ if not installed:
+ entries.append(proposed)
+ encoded = (json.dumps(config, indent=2, ensure_ascii=False) + '\n').encode()
+ # A changed file is never overwritten after our read. Directory operations
+ # happen only after the complete, reviewable configuration was validated.
+ directory.mkdir(mode=0o700, exist_ok=True)
+ hook_dir.mkdir(mode=0o700, exist_ok=True)
+ if (settings.read_bytes() if settings.exists() else None) != original:
+ raise ValueError('Settings changed during setup; retry after reviewing them.')
+ created_hook = not target.exists()
+ if created_hook:
+ with target.open('xb') as handle:
+ handle.write(source)
+ target.chmod(0o600)
+ if not installed:
+ descriptor, temporary = tempfile.mkstemp(prefix='.cbm-atlas-settings-', dir=directory)
+ try:
+ with os.fdopen(descriptor, 'wb') as handle:
+ handle.write(encoded)
+ if (settings.read_bytes() if settings.exists() else None) != original:
+ raise ValueError('Settings changed during setup; nothing was replaced.')
+ os.replace(temporary, settings)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+ return {'installed': True, 'changed': not installed or created_hook,
+ 'settings': str(settings), 'hook': str(target), 'project': project,
+ 'daemon': origin, 'next': 'Start a new Claude Code session in this repository, perform a tool call, then load activity.'}
+
+
+def setup_main():
+ parser = argparse.ArgumentParser(description='Install the local Code Atlas Claude Code tool hook.')
+ parser.add_argument('--install-claude', action='store_true', required=True)
+ parser.add_argument('--root', required=True, help='Exact indexed repository root')
+ parser.add_argument('--project', required=True, help='Exact indexed project name')
+ parser.add_argument('--daemon-url', default='http://127.0.0.1:9749')
+ args = parser.parse_args()
+ try:
+ result = install_claude(args.root, args.project, args.daemon_url)
+ except Exception as error:
+ print('Atlas setup: ' + str(error), file=sys.stderr)
+ return 1
+ print(json.dumps(result, ensure_ascii=False))
+ return 0
+
+
+def main():
+ if '--flush' in sys.argv:
+ try:
+ with open_outbox() as db:
+ flush(db)
+ except Exception:
+ pass
+ return 0
+ try:
+ payload = json.load(sys.stdin)
+ except Exception:
+ return 0
+ try:
+ path = trace_file()
+ directory = os.path.dirname(path)
+ if directory:
+ os.makedirs(directory, exist_ok=True)
+
+ tool = payload.get('tool_name') or ''
+ args = payload.get('tool_input') or {}
+ if not isinstance(args, dict):
+ args = {}
+ file_path = args.get('file_path') or args.get('notebook_path') or ''
+ run = str(payload.get('session_id') or uuid.uuid4())
+ root = str(payload.get('cwd') or os.getcwd())
+ project = os.environ.get('ATLAS_PROJECT', os.path.basename(root.rstrip(os.sep)))
+
+ row = {
+ 'ts': int(time.time() * 1000),
+ 'agent': os.environ.get('ATLAS_AGENT_NAME', 'agent'),
+ 'run': run,
+ 'seq': 0,
+ 'phase': 'end',
+ 'tool': tool,
+ 'path': str(file_path or args.get('path') or ''),
+ # Commands, patterns and descriptions may contain credentials.
+ 'detail': '',
+ 'source': 'tool-hook',
+ }
+ span = line_span(file_path, args) if tool == 'Read' else None
+ if span is not None:
+ row['lines'] = span
+
+ if os.path.isabs(row['path']):
+ row['path'] = os.path.relpath(row['path'], root)
+ with open_outbox() as db:
+ enqueue(db, project, row)
+ flush(db)
+ except Exception:
+ pass
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(setup_main() if '--install-claude' in sys.argv else main())
diff --git a/graph-ui/agents/hooks/test_atlas_trace.py b/graph-ui/agents/hooks/test_atlas_trace.py
new file mode 100644
index 000000000..cc43594b5
--- /dev/null
+++ b/graph-ui/agents/hooks/test_atlas_trace.py
@@ -0,0 +1,218 @@
+"""Reproducible local producer tests; no external network or agent accounts."""
+import concurrent.futures
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+spec = importlib.util.spec_from_file_location('atlas_trace', Path(__file__).with_name('atlas-trace.py'))
+hook = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(hook)
+
+
+class TraceTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.environment = patch.dict(os.environ, {
+ 'ATLAS_TRACE_FILE': os.path.join(self.directory.name, 'events'),
+ 'ATLAS_DAEMON_URL': 'http://127.0.0.1:9749',
+ })
+ self.environment.start()
+
+ def tearDown(self):
+ self.environment.stop()
+ self.directory.cleanup()
+
+ def record(self, number):
+ with hook.open_outbox() as db:
+ hook.enqueue(db, 'fixture', {'run': 'concurrent-run', 'seq': 0, 'ts': number,
+ 'agent': 'fixture', 'tool': 'Read', 'phase': 'end'})
+
+ def assert_private_tool_input_is_omitted(self, tool, field, value, private_marker):
+ root = Path(self.directory.name)
+ source = root / 'src' / 'example.py'
+ payload = {'tool_name': tool, 'session_id': 'privacy-run', 'cwd': str(root),
+ 'tool_input': {field: value, 'file_path': str(source)}}
+ generated = []
+ original_enqueue = hook.enqueue
+
+ def capture_enqueue(db, project, row):
+ generated.append(json.loads(json.dumps(row)))
+ original_enqueue(db, project, row)
+
+ with patch.dict(os.environ, {'ATLAS_PROJECT': 'fixture', 'ATLAS_AGENT_NAME': 'fixture'}), \
+ patch.object(hook.sys, 'argv', ['atlas-trace.py']), \
+ patch.object(hook.sys, 'stdin', io.StringIO(json.dumps(payload))), \
+ patch.object(hook.time, 'time', return_value=1234.5), \
+ patch.object(hook, 'enqueue', side_effect=capture_enqueue), \
+ patch.object(hook.urllib.request, 'build_opener') as transport:
+ transport.return_value.open.side_effect = OSError('offline fixture')
+ self.assertEqual(hook.main(), 0)
+ transport.return_value.open.assert_called_once()
+ request = transport.return_value.open.call_args.args[0]
+
+ with hook.open_outbox() as db:
+ pending = db.execute('SELECT project,payload FROM pending').fetchall()
+ self.assertEqual(len(generated), 1)
+ self.assertEqual(len(pending), 1)
+ self.assertEqual(pending[0][0], 'fixture')
+ outbound = json.loads(request.data)
+ self.assertEqual(outbound['project'], 'fixture')
+ self.assertEqual(len(outbound['events']), 1)
+ for surface, event, serialized in (
+ ('generated', generated[0], json.dumps(generated[0])),
+ ('persisted', json.loads(pending[0][1]), pending[0][1]),
+ ('outgoing', outbound['events'][0], request.data.decode())):
+ with self.subTest(surface=surface):
+ self.assertEqual(event['tool'], tool)
+ self.assertEqual(event['path'], os.path.join('src', 'example.py'))
+ self.assertEqual(event['agent'], 'fixture')
+ self.assertEqual(event['run'], 'privacy-run')
+ self.assertEqual(event['phase'], 'end')
+ self.assertEqual(event['source'], 'tool-hook')
+ self.assertEqual(event['ts'], 1234500)
+ self.assertNotIn(private_marker, serialized)
+ self.assertNotIn(value, serialized)
+
+ def test_command_private_values_never_enter_event_payloads(self):
+ marker = 'SYNTHETIC_COMMAND_PRIVATE_VALUE_71E5'
+ self.assert_private_tool_input_is_omitted(
+ 'Bash', 'command', 'AUTH_TOKEN=' + marker + ' example-tool --quiet', marker)
+
+ def test_search_patterns_never_enter_event_payloads(self):
+ marker = 'SYNTHETIC_PATTERN_PRIVATE_VALUE_83A6'
+ self.assert_private_tool_input_is_omitted('Grep', 'pattern', marker, marker)
+
+ def test_description_private_values_never_enter_event_payloads(self):
+ marker = 'SYNTHETIC_DESCRIPTION_PRIVATE_VALUE_92B7'
+ self.assert_private_tool_input_is_omitted(
+ 'Bash', 'description', 'Inspect the value ' + marker, marker)
+
+ def test_concurrent_hooks_allocate_unique_sequences_and_keep_pending_after_failure(self):
+ with hook.open_outbox():
+ pass
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
+ list(pool.map(self.record, range(20)))
+ with hook.open_outbox() as db:
+ rows = db.execute('SELECT payload FROM pending').fetchall()
+ self.assertEqual(sorted(json.loads(row[0])['seq'] for row in rows), list(range(1, 21)))
+ with patch.object(hook.urllib.request, 'build_opener', side_effect=OSError('offline')):
+ with self.assertRaises(OSError):
+ hook.flush(db)
+ self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 20)
+
+ def test_external_destinations_and_credentials_are_never_contacted(self):
+ self.record(1)
+ for origin in ('https://example.com', 'http://example.com',
+ 'http://127.0.0.1.evil.test', 'http://secret@localhost:9749',
+ 'http://localhost:9749/redirect', 'http://localhost:9749?secret=1'):
+ with patch.dict(os.environ, {'ATLAS_DAEMON_URL': origin}), \
+ patch.object(hook.urllib.request, 'build_opener') as network, \
+ hook.open_outbox() as db:
+ hook.flush(db)
+ network.assert_not_called()
+ self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 1)
+
+ def test_explicit_setup_preserves_settings_is_idempotent_and_escapes_names(self):
+ root = Path(self.directory.name) / "repo with 'quote'"
+ (root / '.claude').mkdir(parents=True)
+ settings = root / '.claude/settings.local.json'
+ original = {'permissions': {'allow': ['Read']}, 'hooks': {'PostToolUse': [
+ {'matcher': 'Read', 'hooks': [{'type': 'command', 'command': 'existing-hook'}]}]}}
+ settings.write_text(json.dumps(original))
+ with patch.object(hook.urllib.request, 'build_opener') as network:
+ result = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749')
+ again = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749')
+ network.assert_not_called()
+ self.assertTrue(result['changed'])
+ self.assertFalse(again['changed'])
+ installed = json.loads(settings.read_text())
+ self.assertEqual(installed['permissions'], original['permissions'])
+ self.assertEqual(installed['hooks']['PostToolUse'][0], original['hooks']['PostToolUse'][0])
+ self.assertEqual(len(installed['hooks']['PostToolUse']), 2)
+ command = installed['hooks']['PostToolUse'][1]['hooks'][0]['command']
+ self.assertEqual(hook.shlex.split(command)[0], "ATLAS_PROJECT=project'; echo bad")
+ self.assertEqual(hook.shlex.split(command)[-1], str(root.resolve() / '.claude/hooks/cbm-atlas-trace.py'))
+ before = settings.read_bytes()
+ with self.assertRaises(ValueError):
+ hook.install_claude(root, 'another-project', 'http://127.0.0.1:9749')
+ self.assertEqual(settings.read_bytes(), before)
+
+ def test_setup_refuses_symlinks_malformed_settings_and_external_daemons_before_writes(self):
+ root = Path(self.directory.name) / 'repo'
+ root.mkdir()
+ with self.assertRaises(ValueError):
+ hook.install_claude(root, 'fixture', 'https://example.com')
+ self.assertFalse((root / '.claude').exists())
+ (root / '.claude').mkdir()
+ settings = root / '.claude/settings.local.json'
+ settings.write_text('malformed user data')
+ with self.assertRaises(ValueError):
+ hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
+ self.assertEqual(settings.read_text(), 'malformed user data')
+ settings.unlink()
+ target = root / 'external-settings.json'
+ target.write_text('{}')
+ settings.symlink_to(target)
+ with self.assertRaises(ValueError):
+ hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
+ self.assertEqual(target.read_text(), '{}')
+ self.assertFalse((root / '.claude/hooks').exists())
+
+ def test_setup_refuses_a_second_execution_with_a_different_matcher_or_timeout(self):
+ root = Path(self.directory.name) / 'TEST fixture'
+ root.mkdir()
+ hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
+ settings = root / '.claude/settings.local.json'
+ pristine = json.loads(settings.read_text())
+ for change in ('matcher', 'timeout', 'relative-command'):
+ config = json.loads(json.dumps(pristine))
+ entry = config['hooks']['PostToolUse'][0]
+ if change == 'matcher':
+ entry['matcher'] = 'Read'
+ elif change == 'timeout':
+ entry['hooks'][0]['timeout'] = 5
+ else:
+ entry['hooks'][0]['command'] = 'python3 .claude/hooks/cbm-atlas-trace.py'
+ settings.write_text(json.dumps(config))
+ before = settings.read_bytes()
+ with self.assertRaisesRegex(ValueError, 'existing Atlas hook differs'):
+ hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
+ self.assertEqual(settings.read_bytes(), before)
+ self.assertEqual(len(json.loads(settings.read_text())['hooks']['PostToolUse']), 1)
+
+ def test_post_edit_range_is_unknown_instead_of_an_unmodified_duplicate_occurrence(self):
+ source = Path(self.directory.name) / 'edited.txt'
+ source.write_text('new value\nold value\n')
+ with patch('builtins.open', side_effect=AssertionError('Metadata hook must not read source')):
+ self.assertIsNone(hook.line_span(str(source), {'old_string': 'old value', 'new_string': 'new value'}))
+ self.assertEqual(hook.line_span(str(source), {'offset': 8, 'limit': 3}), [8, 10])
+ self.assertIsNone(hook.line_span(str(source), {'offset': True}))
+
+ def test_acknowledged_duplicate_retry_removes_only_acknowledged_rows(self):
+ self.record(1)
+ self.record(2)
+ class Response:
+ status = 200
+ def __enter__(self): return self
+ def __exit__(self, *args): return None
+ def read(self): return b'{"accepted":0,"duplicates":2}'
+ class Opener:
+ def open(self, request, timeout):
+ self_body = json.loads(request.data)
+ assert request.full_url == 'http://127.0.0.1:9749/api/agent-events'
+ assert self_body['project'] == 'fixture'
+ assert len(self_body['events']) == 2
+ return Response()
+ with patch.object(hook.urllib.request, 'build_opener', return_value=Opener()), \
+ hook.open_outbox() as db:
+ hook.flush(db)
+ self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/graph-ui/components.json b/graph-ui/components.json
deleted file mode 100644
index 86ac0b1c9..000000000
--- a/graph-ui/components.json
+++ /dev/null
@@ -1 +0,0 @@
-{"$schema":"https://ui.shadcn.com/schema.json","style":"new-york","rsc":false,"tsx":true,"tailwind":{"config":"","css":"src/styles/globals.css","baseColor":"neutral","cssVariables":true},"aliases":{"components":"@/components","utils":"@/lib/utils","ui":"@/components/ui","lib":"@/lib","hooks":"@/hooks"},"iconLibrary":"lucide"}
diff --git a/graph-ui/docs/adr/0001-modellwahl.md b/graph-ui/docs/adr/0001-modellwahl.md
new file mode 100644
index 000000000..ce562d6bd
--- /dev/null
+++ b/graph-ui/docs/adr/0001-modellwahl.md
@@ -0,0 +1,70 @@
+# ADR 0001: Optional local inference and model evidence
+
+## Decision
+
+CBM Lens offers optional browser inference. Weights are downloaded only after the
+user opts in; they are not embedded in the executable. Model selection remains a
+user choice. Browser model IDs, fixed revisions, allowed files and licenses live in
+`src/browser-ai/model-policy.ts`; [THIRD_PARTY.md](../../THIRD_PARTY.md) documents them.
+
+The older GGUF sidecar catalogue remains supported by local scripts and its recorded
+evaluation. It is separate from the browser ONNX catalogue. Its measurements do not
+predict browser performance, another machine's performance or a model's general rank.
+
+## Sidecar model sources
+
+The six Q4_K_M candidates below were recorded in August 2026. Class A used a 3072-token
+context and class B used 8192 tokens. Exact download sizes refer to those evaluated
+files, not runtime memory. Checksums are retained in `models/SHA256SUMS`.
+
+| Model | Class | Download source | Recorded size | License recorded for the base model |
+| --- | --- | --- | --- | --- |
+| Qwen3.5-2B | A | [unsloth/Qwen3.5-2B-GGUF](https://huggingface.co/unsloth/Qwen3.5-2B-GGUF) | 1280835840 Bytes | Apache 2.0 |
+| LFM2.5-1.2B-Instruct | A | [LiquidAI/LFM2.5-1.2B-Instruct-GGUF](https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF) | 730895168 Bytes | LFM Open License v1.0; commercial terms apply at $10M annual revenue |
+| MiniCPM5-1B | A | [openbmb/MiniCPM5-1B-GGUF](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) | 688065920 Bytes | Apache 2.0 |
+| Qwen2.5-Coder-1.5B-Instruct | A | [Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF](https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF) | 1117320768 Bytes | Apache 2.0 |
+| Qwen3.5-4B | B | [unsloth/Qwen3.5-4B-MTP-GGUF](https://huggingface.co/unsloth/Qwen3.5-4B-MTP-GGUF) | 2834975040 Bytes | Apache 2.0 |
+| Gemma 4 E4B-it | B | [unsloth/gemma-4-E4B-it-GGUF](https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF) | 4977171584 Bytes | Apache 2.0 |
+
+Review current model-card terms before downloading or redistribution. The recorded
+LFM license allows free commercial use below $10M annual revenue; use beyond that
+threshold requires separate consideration. No evaluation result overrides licensing.
+
+## Evaluation limits
+
+`verification/w5/eval.json` records 44 fixture questions, including 23 in German,
+with temperature 0 and seed 42. The figures below summarize that saved run. Citation
+compliance measures the evaluation's citation rule, not factual correctness.
+
+| Model | Class | Pass rate | Citation compliance | Mean tokens/s, rounded |
+| --- | --- | --- | --- | --- |
+| Qwen3.5-2B | A | 0.682 | 0.932 | 86 |
+| LFM2.5-1.2B | A | 0.295 | 0.432 | 110 |
+| MiniCPM5-1B | A | 0.250 | 0.545 | 171 |
+| Qwen2.5-Coder-1.5B | A | 0.227 | 0.705 | 84 |
+| Qwen3.5-4B | B | 0.818 | 0.955 | 38 |
+| gemma-4-E4B | B | 0.841 | 1.000 | 32 |
+
+The recorded class recommendations were Qwen3.5-2B and gemma-4-E4B, with pass-rate
+and citation thresholds of 0.6 and 0.9. They are recommendations, not installed
+defaults. A reported repeat run has no separate saved artifact and is not independent
+reproduction evidence. The original record does not measure unscorable citations;
+missing measurements must not be displayed as zero.
+
+## Engine boundary
+
+The sidecar evaluation used llama.cpp on Apple Silicon with Metal and GGUF files.
+Its source/build record is in [runtime provenance](../../vendor/llama/HERKUNFT.md).
+`llm/start.sh` also supports a local model cache and OpenAI-compatible transport;
+the class-specific script options reproduce the recorded evaluation.
+
+Browser inference uses a different runtime and format. Compare actual device and
+workload behavior before drawing performance conclusions, especially prompt prefill,
+GPU memory limits and large-file handling. Relevant engine sources retained from
+the original investigation are [llama.cpp](https://github.com/ggml-org/llama.cpp),
+[wllama](https://github.com/ngxson/wllama), [WebLLM](https://webllm.mlc.ai/docs/),
+[Transformers.js](https://github.com/huggingface/transformers.js),
+[WebGPU limits](https://developer.mozilla.org/en-US/docs/Web/API/GPUSupportedLimits),
+and the [ggml WebGPU study](https://arxiv.org/html/2605.20706v1).
+These references explain the formats and tradeoffs; the sidecar timings above are
+not browser benchmarks.
diff --git a/graph-ui/eval/questions.json b/graph-ui/eval/questions.json
new file mode 100644
index 000000000..a70732996
--- /dev/null
+++ b/graph-ui/eval/questions.json
@@ -0,0 +1,357 @@
+[
+ {
+ "id": "who-01-de",
+ "class": "who-calls",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Wer ruft createUser auf?",
+ "expected": ["registerUserRoutes", "create"],
+ "depth": 1
+ },
+ {
+ "id": "who-02-en",
+ "class": "who-calls",
+ "focusQn": "src.services.userService.listUsers",
+ "question": "Who calls listUsers?",
+ "expected": ["registerUserRoutes", "createUser"],
+ "depth": 1
+ },
+ {
+ "id": "who-03-de",
+ "class": "who-calls",
+ "focusQn": "src.util.validate.validateUser",
+ "question": "Wer ruft validateUser?",
+ "expected": ["createUser"],
+ "depth": 1
+ },
+ {
+ "id": "who-04-en",
+ "class": "who-calls",
+ "focusQn": "src.repo.db.insert",
+ "question": "Who calls insert?",
+ "expected": ["createUser"],
+ "depth": 1
+ },
+ {
+ "id": "who-05-de",
+ "class": "who-calls",
+ "focusQn": "src.services.orderService.getOrder",
+ "question": "Wer ruft getOrder?",
+ "expected": ["registerOrderRoutes"],
+ "depth": 1
+ },
+ {
+ "id": "who-06-en",
+ "class": "who-calls",
+ "focusQn": "src.repo.db.query",
+ "question": "Who calls query?",
+ "expected": ["listUsers"],
+ "depth": 1
+ },
+ {
+ "id": "who-07-de",
+ "class": "who-calls",
+ "focusQn": "src.routes.users.registerUserRoutes",
+ "question": "Wer ruft registerUserRoutes?",
+ "expected": ["createApp"],
+ "depth": 1
+ },
+ {
+ "id": "who-08-en",
+ "class": "who-calls",
+ "focusQn": "src.config.loadConfig",
+ "question": "Who calls loadConfig?",
+ "expected": ["main"],
+ "depth": 1
+ },
+ {
+ "id": "what-01-de",
+ "class": "what-is",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Was macht createUser?",
+ "expected": ["validateUser", "insert"],
+ "depth": 1
+ },
+ {
+ "id": "what-02-en",
+ "class": "what-is",
+ "focusQn": "src.services.userService.listUsers",
+ "question": "What does listUsers do?",
+ "expected": ["query"],
+ "depth": 1
+ },
+ {
+ "id": "what-03-de",
+ "class": "what-is",
+ "focusQn": "src.services.orderService.getOrder",
+ "question": "Was macht getOrder?",
+ "expected": ["validateId", "query"],
+ "depth": 1
+ },
+ {
+ "id": "what-04-en",
+ "class": "what-is",
+ "focusQn": "src.repo.db.hotspotScan",
+ "question": "What does hotspotScan do?",
+ "expected": ["query"],
+ "depth": 1
+ },
+ {
+ "id": "what-05-de",
+ "class": "what-is",
+ "focusQn": "src.server.main",
+ "question": "Was macht main?",
+ "expected": ["loadConfig", "createApp"],
+ "depth": 1
+ },
+ {
+ "id": "what-06-en",
+ "class": "what-is",
+ "focusQn": "src.server.createApp",
+ "question": "What does createApp do?",
+ "expected": ["registerUserRoutes"],
+ "depth": 1
+ },
+ {
+ "id": "what-07-de",
+ "class": "what-is",
+ "focusQn": "src.config.loadConfig",
+ "question": "Was macht loadConfig?",
+ "expected": ["loadConfig"],
+ "depth": 0
+ },
+ {
+ "id": "what-08-en",
+ "class": "what-is",
+ "focusQn": "src.util.validate.ValidationError",
+ "question": "What is ValidationError?",
+ "expected": ["validate.ts"],
+ "depth": 0
+ },
+ {
+ "id": "what-09-de",
+ "class": "what-is",
+ "focusQn": "src.services.userService.create",
+ "question": "Was tut `userService.create`?",
+ "expected": ["createUser"],
+ "depth": 1
+ },
+ {
+ "id": "what-10-en",
+ "class": "what-is",
+ "focusQn": "src.routes.orders.registerOrderRoutes",
+ "question": "What does registerOrderRoutes do?",
+ "expected": ["getOrder"],
+ "depth": 1
+ },
+ {
+ "id": "err-01-de",
+ "class": "why-error",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Welche Fehler kann createUser werfen?",
+ "expected": ["ValidationError"],
+ "depth": 1
+ },
+ {
+ "id": "err-02-en",
+ "class": "why-error",
+ "focusQn": "src.util.validate.validateUser",
+ "question": "What errors can validateUser raise?",
+ "expected": ["ValidationError"],
+ "depth": 1
+ },
+ {
+ "id": "err-03-de",
+ "class": "why-error",
+ "focusQn": "src.util.validate.validateId",
+ "question": "Welche Ausnahme wirft validateId?",
+ "expected": ["ValidationError"],
+ "depth": 1
+ },
+ {
+ "id": "err-04-en",
+ "class": "why-error",
+ "focusQn": "src.services.orderService.getOrder",
+ "question": "What errors can getOrder raise?",
+ "expected": ["ValidationError"],
+ "depth": 1
+ },
+ {
+ "id": "err-05-de",
+ "class": "why-error",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Warum schlaegt createUser fehl, wenn DB_URL nicht gesetzt ist?",
+ "expected": ["ValidationError", "DB_URL"],
+ "depth": 1
+ },
+ {
+ "id": "err-06-en",
+ "class": "why-error",
+ "focusQn": "src.repo.db.insert",
+ "question": "What errors can insert raise?",
+ "expected": ["insert"],
+ "forbidden": ["ValidationError"],
+ "depth": 1
+ },
+ {
+ "id": "if-01-de",
+ "class": "what-if",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Was passiert, wenn ich createUser aendere?",
+ "expected": ["registerUserRoutes", "create"],
+ "depth": 1
+ },
+ {
+ "id": "if-02-en",
+ "class": "what-if",
+ "focusQn": "src.services.userService.listUsers",
+ "question": "What happens if I change listUsers?",
+ "expected": ["registerUserRoutes"],
+ "depth": 1
+ },
+ {
+ "id": "if-03-de",
+ "class": "what-if",
+ "focusQn": "src.repo.db.query",
+ "question": "Welche Auswirkung haette eine Aenderung an query?",
+ "expected": ["listUsers"],
+ "depth": 1
+ },
+ {
+ "id": "if-04-en",
+ "class": "what-if",
+ "focusQn": "src.util.validate.validateUser",
+ "question": "What is the blast radius of validateUser?",
+ "expected": ["createUser"],
+ "depth": 1
+ },
+ {
+ "id": "if-05-de",
+ "class": "what-if",
+ "focusQn": "src.repo.db.insert",
+ "question": "Was passiert, wenn ich insert aendere?",
+ "expected": ["createUser"],
+ "depth": 2
+ },
+ {
+ "id": "if-06-en",
+ "class": "what-if",
+ "focusQn": "src.routes.users.registerUserRoutes",
+ "question": "What happens if registerUserRoutes changes?",
+ "expected": ["createApp"],
+ "depth": 1
+ },
+ {
+ "id": "entry-01-de",
+ "class": "where-entry",
+ "focusQn": "src.server.main",
+ "question": "Welche Routen gibt es in diesem Projekt?",
+ "expected": ["/users"],
+ "depth": 1
+ },
+ {
+ "id": "entry-02-en",
+ "class": "where-entry",
+ "focusQn": "src.server.main",
+ "question": "Which HTTP routes does this project expose?",
+ "expected": ["/users", "/orders"],
+ "depth": 1
+ },
+ {
+ "id": "entry-03-de",
+ "class": "where-entry",
+ "focusQn": "src.server.main",
+ "question": "Wo faengt dieses Projekt an?",
+ "expected": ["src/server.ts"],
+ "depth": 1
+ },
+ {
+ "id": "entry-04-en",
+ "class": "where-entry",
+ "focusQn": "src.server.main",
+ "question": "Where do I start reading this project?",
+ "expected": ["src/server.ts"],
+ "depth": 1
+ },
+ {
+ "id": "entry-05-de",
+ "class": "where-entry",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Welche Route fuehrt zu createUser?",
+ "expected": ["/users"],
+ "depth": 1
+ },
+ {
+ "id": "cmp-01-de",
+ "class": "compare",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Was ist der Unterschied zwischen createUser und listUsers?",
+ "expected": ["validateUser", "query"],
+ "depth": 1
+ },
+ {
+ "id": "cmp-02-en",
+ "class": "compare",
+ "focusQn": "src.services.orderService.getOrder",
+ "question": "What is the difference between getOrder and createUser?",
+ "expected": ["validateId", "validateUser"],
+ "depth": 1
+ },
+ {
+ "id": "cmp-03-de",
+ "class": "compare",
+ "focusQn": "src.services.userService.listUsers",
+ "question": "Vergleiche listUsers und createUser.",
+ "expected": ["query", "validateUser"],
+ "depth": 1
+ },
+ {
+ "id": "cmp-04-en",
+ "class": "compare",
+ "focusQn": "src.repo.db.hotspotScan",
+ "question": "What is the difference between hotspotScan and walk?",
+ "expected": ["query"],
+ "depth": 1
+ },
+ {
+ "id": "over-01-de",
+ "class": "overview",
+ "focusQn": "src.server.main",
+ "question": "Gib mir einen Ueberblick ueber dieses Projekt.",
+ "expected": ["TypeScript"],
+ "depth": 1
+ },
+ {
+ "id": "over-02-en",
+ "class": "overview",
+ "focusQn": "src.server.main",
+ "question": "What is this project made of?",
+ "expected": ["TypeScript"],
+ "depth": 1
+ },
+ {
+ "id": "over-03-de",
+ "class": "overview",
+ "focusQn": "src.server.main",
+ "question": "Wie ist die Architektur dieses Projekts aufgebaut?",
+ "expected": ["symbol"],
+ "depth": 1
+ },
+ {
+ "id": "honest-01-de",
+ "class": "other",
+ "focusQn": "src.services.userService.createUser",
+ "question": "Hallo, alles klar bei dir?",
+ "expected": ["no card"],
+ "forbidden": ["createUser"],
+ "depth": 1
+ },
+ {
+ "id": "honest-02-en",
+ "class": "what-is",
+ "focusQn": "src.services.userService.createUser",
+ "question": "What is @notASymbolAnywhereInThisRepo?",
+ "expected": ["no card"],
+ "forbidden": ["createUser"],
+ "depth": 1
+ }
+]
diff --git a/graph-ui/fixtures/adr/cbm-preview.md b/graph-ui/fixtures/adr/cbm-preview.md
new file mode 100644
index 000000000..da314a950
--- /dev/null
+++ b/graph-ui/fixtures/adr/cbm-preview.md
@@ -0,0 +1,63 @@
+# Architecture decisions
+
+> Preview examples for cbm-preview. These editable notes capture frontend design choices discussed during development; they are not a historical ADR archive or graph-inferred facts. Status: proposed for review.
+
+## 001: Explain architecture from evidence
+
+**Status:** Proposed · **Scope:** Architecture workspace
+
+### Context
+
+A repository map explains where code lives. System structure and behavior also need to explain relationships, boundaries, and execution steps. A convincing picture should remain traceable to its inputs.
+
+### Decision
+
+Combine indexed graph relationships with source evidence. Keep the repository map, routes, hotspots, system structure, and behavior as distinct lenses. Mark inferred relationships as inferred, and make their evidence inspectable. Do not embed project-specific architectural explanations in the visualization code.
+
+### Consequences
+
+- The same analysis can be applied to other indexed repositories.
+- Missing evidence remains a visible limitation rather than a fabricated connection.
+- A source or graph change can alter the view; these written decisions change only when someone edits them.
+
+**Implementation references:** `graph-ui/src/architecture/`, `src/store/architecture_projection.c`.
+
+## 002: Keep browser AI optional
+
+**Status:** Proposed · **Scope:** Local chat
+
+### Context
+
+Code exploration should work before any model is downloaded. Users may want to ask about a file or a literal code selection without sending source to a hosted model.
+
+### Decision
+
+Run the chat model locally in the browser after explicit setup. Download weights only after opt-in. Attach the selected code or graph context to the next prompt and render answers as Markdown.
+
+### Consequences
+
+| Benefit | Tradeoff |
+| --- | --- |
+| Exploration works without model setup | Chat is unavailable until a model is enabled |
+| Inference stays on the device | Model quality and speed depend on local resources |
+| The prompt can use exact marked code | A bounded context cannot include an entire large repository |
+
+**Implementation references:** `graph-ui/src/browser-ai/`, `graph-ui/src/reader/MonacoReader.tsx`.
+
+## 003: Keep agent activity experimental
+
+**Status:** Proposed · **Scope:** Agents workspace
+
+### Context
+
+The current integration records events from explicitly configured agent hooks. The UI cannot infer every agent operation merely by observing the graph.
+
+### Decision
+
+Hide Agents unless the experimental build flag is enabled. Treat hook events as observed activity, and avoid implying complete task execution, tool outcomes, or duration data.
+
+### Consequences
+
+The default navigation remains focused. Further agent adapters and a richer event contract can be introduced before promoting the view to a standard workspace.
+
+**Implementation references:** `graph-ui/src/app/feature-flags.ts`, `graph-ui/agents/hooks/atlas-trace.py`, `src/ui/activity.c`.
diff --git a/graph-ui/fixtures/adr/train-ticket.md b/graph-ui/fixtures/adr/train-ticket.md
new file mode 100644
index 000000000..f6ae48f32
--- /dev/null
+++ b/graph-ui/fixtures/adr/train-ticket.md
@@ -0,0 +1,44 @@
+# Architecture decisions
+
+> Preview examples for the indexed Train Ticket repository. These are proposed review notes for exercising the ADR workspace, not decisions attributed to the upstream maintainers.
+
+## 001: Review services as deployment boundaries
+
+**Status:** Proposed · **Scope:** Routes review
+
+### Context
+
+A large service graph is difficult to understand when every source file has equal visual weight. Deployment declarations provide another useful level of organization.
+
+### Decision
+
+Use detected service and container boundaries to organize the routes review. Keep the declaration behind each boundary inspectable. Treat a deployment dependency and a source-level request as different kinds of evidence.
+
+### Consequences
+
+- The initial view can show relationships between services before drilling into handlers.
+- A declared dependency alone does not prove that a request is sent.
+- Services without discovered connections should remain visible.
+
+## 002: Follow a request through its evidence
+
+**Status:** Proposed · **Scope:** Cross-service investigation
+
+### Context
+
+Matching a caller to a route helps explain the system, but static analysis may not resolve dynamic hosts, route parameters, or runtime configuration.
+
+### Decision
+
+During review, follow a connection from its caller through the route evidence to the destination handler. Keep unresolved destinations visible as unknowns. Use source inspection to verify a suspected relationship before treating it as established.
+
+### Review checklist
+
+- [ ] Inspect an outgoing request in the selected service.
+- [ ] Compare its path and method with the destination route.
+- [ ] Check relevant configuration and service naming.
+- [ ] Record unresolved runtime assumptions here.
+
+### Consequences
+
+This creates a repeatable review path while avoiding claims that a static graph proves runtime behavior.
diff --git a/graph-ui/fixtures/adr/voting-services.md b/graph-ui/fixtures/adr/voting-services.md
new file mode 100644
index 000000000..c253837e2
--- /dev/null
+++ b/graph-ui/fixtures/adr/voting-services.md
@@ -0,0 +1,25 @@
+# Architecture decisions
+
+> Preview example for voting-services. This proposed review note is test content, not an upstream architectural decision.
+
+## 001: Distinguish declared and observed connections
+
+**Status:** Proposed · **Scope:** Service map review
+
+### Context
+
+A small connected application is useful for checking whether a service visualization makes each relationship understandable.
+
+### Decision
+
+Review deployment dependencies, request relationships, and datastore connections separately. Inspect the source or configuration evidence for each edge. Do not interpret an edge count as traffic volume or a static dependency as an observed runtime call.
+
+### Consequences
+
+The service map can explain what is connected and why without implying measurements that were never collected. Missing connections become concrete questions for further inspection.
+
+### Next review
+
+- [ ] Select a service and inspect its neighboring connections.
+- [ ] Verify one connection against source or configuration.
+- [ ] Edit this note with the outcome and save it.
diff --git a/graph-ui/fixtures/agent-events/HERKUNFT.md b/graph-ui/fixtures/agent-events/HERKUNFT.md
new file mode 100644
index 000000000..be44c6f07
--- /dev/null
+++ b/graph-ui/fixtures/agent-events/HERKUNFT.md
@@ -0,0 +1,56 @@
+# Agent-event fixture provenance
+
+These stable replay inputs exercise activity visualization without requiring a live
+coding session. They contain no source contents or tool output. They are test
+fixtures, not evidence of current agent activity.
+
+## `w11a-replay.jsonl`
+
+Derived from a real tool-hook recording made while working on this repository on
+29-30 August 2026. The private original is not committed because it contains local
+paths and commands. This fixture preserves its event shape, relative tool mix,
+initial timestamp (`1788038561000`) and 44 consecutive time intervals in milliseconds:
+
+```text
+10065, 13599, 115703, 5123, 18182, 6789, 1140, 13387, 5243, 5570, 6676,
+21168, 51146, 6330, 6339, 28257, 4029, 318, 2709, 709, 83, 6176, 1116,
+3373, 3642, 3149, 2275, 4175, 2884, 4066, 2310, 2028, 2838, 33596, 5878,
+17621, 826, 5788, 11062, 6988, 5724, 10001, 17767, 13461
+```
+
+The intervals came from the first 45-event window without a pause over two minutes,
+beginning at line 57 of the original. The following content was deliberately changed:
+
+- Absolute paths became relative `fixtures/atlas-sample` paths. `package.json` is
+ intentionally outside the indexed graph to exercise unmapped activity.
+- One original agent became the synthetic roles `implementer`, `checker` and
+ `explorer`; their eight-character run IDs are invented.
+- Explicit `lines` ranges were added from fixture symbols. The original hook did
+ not record line ranges.
+- Run `b7e4d19c` jumps from sequence 6 to 9 to expose two missing events.
+- Two synthetic `intent` fields exercise explicitly labeled self-reports.
+- `detail` values describe fixture operations instead of retaining private commands.
+
+The result contains 45 events, three runs, four work types, one two-event sequence
+gap, two self-reports and one path outside the index.
+
+## `w11b-replay.jsonl`
+
+Entirely constructed test data, not a session recording. Its paths and line spans
+come from `fixtures/atlas-sample`; timestamps and activity are deliberately chosen.
+
+- Eight agents plus the browser reader exercise the eight-body display limit.
+- `explorer` and `planner` end 75 and 85 seconds before the last event. Both are
+ idle but still within retention; the oldest actor exercises the display cap.
+- `implementer` writes `createUser` lines 23-36 five times in 1.8 seconds to test
+ aggregation into one visible wave, and visits fifteen symbols to exceed trail limits.
+- `reviewer`, `indexer` and `tracer` end at `hotspotScan` in `src/repo/db.ts`, lines
+ 39-54, to test deterministic orbit separation at one symbol.
+- Activity rates differ: fifteen recent events for `implementer`, two for `packer`,
+ none for idle `planner`. One `package.json` path remains unmapped and one event
+ has a synthetic self-reported `intent`.
+
+There are 73 events across eight runs and four work types over 145 seconds. Last
+activity offsets are 0, 3.8, 4.4, 5, 7, 10, 75 and 85 seconds for `implementer`,
+`tracer`, `indexer`, `reviewer`, `packer`, `checker`, `explorer` and `planner`.
+The fixture does not encode progress, quality assessments or source contents.
diff --git a/graph-ui/fixtures/agent-events/w11a-replay.jsonl b/graph-ui/fixtures/agent-events/w11a-replay.jsonl
new file mode 100644
index 000000000..7dad2bc35
--- /dev/null
+++ b/graph-ui/fixtures/agent-events/w11a-replay.jsonl
@@ -0,0 +1,45 @@
+{"ts": 1788038561000, "agent": "explorer", "run": "c3a80f52", "seq": 1, "phase": "end", "tool": "Grep", "path": "src", "detail": "validate"}
+{"ts": 1788038571065, "agent": "implementer", "run": "a1f2c3d4", "seq": 1, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "detail": "read the whole module before touching it", "lines": [1, 43]}
+{"ts": 1788038584664, "agent": "checker", "run": "b7e4d19c", "seq": 1, "phase": "end", "tool": "Read", "path": "package.json", "detail": "read the scripts of the fixture repository"}
+{"ts": 1788038700367, "agent": "explorer", "run": "c3a80f52", "seq": 2, "phase": "end", "tool": "Grep", "path": "src", "detail": "createUser"}
+{"ts": 1788038705490, "agent": "implementer", "run": "a1f2c3d4", "seq": 2, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "detail": "read validateUser", "lines": [19, 31]}
+{"ts": 1788038723672, "agent": "implementer", "run": "a1f2c3d4", "seq": 3, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "replaced the validation branch of createUser", "lines": [24, 30]}
+{"ts": 1788038730461, "agent": "checker", "run": "b7e4d19c", "seq": 2, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "detail": "read the whole file"}
+{"ts": 1788038731601, "agent": "explorer", "run": "c3a80f52", "seq": 3, "phase": "end", "tool": "Grep", "path": "src", "detail": "query"}
+{"ts": 1788038744988, "agent": "implementer", "run": "a1f2c3d4", "seq": 4, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "reflowed createUser after the edit above", "lines": [23, 36]}
+{"ts": 1788038750231, "agent": "checker", "run": "b7e4d19c", "seq": 3, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788038755801, "agent": "explorer", "run": "c3a80f52", "seq": 4, "phase": "end", "tool": "Read", "path": "src/routes/users.ts", "detail": "read registerUserRoutes", "lines": [7, 18]}
+{"ts": 1788038762477, "agent": "implementer", "run": "a1f2c3d4", "seq": 5, "phase": "end", "tool": "Edit", "path": "src/util/validate.ts", "detail": "validateUser now names the field it rejected", "lines": [19, 31]}
+{"ts": 1788038783645, "agent": "checker", "run": "b7e4d19c", "seq": 4, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788038834791, "agent": "explorer", "run": "c3a80f52", "seq": 5, "phase": "end", "tool": "Grep", "path": "src", "detail": "Order"}
+{"ts": 1788038841121, "agent": "implementer", "run": "a1f2c3d4", "seq": 6, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "detail": "read getOrder", "lines": [8, 26]}
+{"ts": 1788038847460, "agent": "checker", "run": "b7e4d19c", "seq": 5, "phase": "end", "tool": "Read", "path": "test/userService.test.ts", "detail": "read the test file itself"}
+{"ts": 1788038875717, "agent": "implementer", "run": "a1f2c3d4", "seq": 7, "phase": "end", "tool": "Edit", "path": "src/services/orderService.ts", "detail": "create now carries the customer id it was given", "lines": [30, 35]}
+{"ts": 1788038879746, "agent": "explorer", "run": "c3a80f52", "seq": 6, "phase": "end", "tool": "Grep", "path": "src", "detail": "insert"}
+{"ts": 1788038880064, "agent": "checker", "run": "b7e4d19c", "seq": 6, "phase": "end", "tool": "Bash", "path": "", "detail": "node --test tests/scaffold/"}
+{"ts": 1788038882773, "agent": "implementer", "run": "a1f2c3d4", "seq": 8, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "detail": "read insert", "lines": [31, 35]}
+{"ts": 1788038883482, "agent": "explorer", "run": "c3a80f52", "seq": 7, "phase": "end", "tool": "Read", "path": "src/config.ts", "detail": "read loadConfig", "lines": [11, 18]}
+{"ts": 1788038883565, "agent": "implementer", "run": "a1f2c3d4", "seq": 9, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "detail": "insert writes the payload it was handed", "lines": [31, 35], "intent": "making insert keep the payload instead of dropping it"}
+{"ts": 1788038889741, "agent": "checker", "run": "b7e4d19c", "seq": 9, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788038890857, "agent": "explorer", "run": "c3a80f52", "seq": 8, "phase": "end", "tool": "Grep", "path": "src", "detail": "walk"}
+{"ts": 1788038894230, "agent": "implementer", "run": "a1f2c3d4", "seq": 10, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "detail": "read hotspotScan", "lines": [39, 54]}
+{"ts": 1788038897872, "agent": "checker", "run": "b7e4d19c", "seq": 10, "phase": "end", "tool": "Read", "path": "src/server.ts", "detail": "read createApp", "lines": [32, 37]}
+{"ts": 1788038901021, "agent": "implementer", "run": "a1f2c3d4", "seq": 11, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "detail": "hotspotScan stops at the first needle it finds", "lines": [39, 54]}
+{"ts": 1788038903296, "agent": "explorer", "run": "c3a80f52", "seq": 9, "phase": "end", "tool": "Read", "path": "src/types.ts", "detail": "read UserEntity", "lines": [37, 53]}
+{"ts": 1788038907471, "agent": "checker", "run": "b7e4d19c", "seq": 11, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788038910355, "agent": "implementer", "run": "a1f2c3d4", "seq": 12, "phase": "end", "tool": "Write", "path": "src/util/validate.ts", "detail": "wrote validateId over the old body", "lines": [33, 38]}
+{"ts": 1788038914421, "agent": "explorer", "run": "c3a80f52", "seq": 10, "phase": "end", "tool": "Grep", "path": "src", "detail": "Router"}
+{"ts": 1788038916731, "agent": "checker", "run": "b7e4d19c", "seq": 12, "phase": "end", "tool": "Read", "path": "src/routes/orders.ts", "detail": "read the whole route module"}
+{"ts": 1788038918759, "agent": "implementer", "run": "a1f2c3d4", "seq": 13, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "detail": "read listUsers", "lines": [18, 21]}
+{"ts": 1788038921597, "agent": "explorer", "run": "c3a80f52", "seq": 11, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "detail": "read ValidationError", "lines": [4, 12]}
+{"ts": 1788038955193, "agent": "checker", "run": "b7e4d19c", "seq": 13, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788038961071, "agent": "implementer", "run": "a1f2c3d4", "seq": 14, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "listUsers takes the limit it is given", "lines": [18, 21]}
+{"ts": 1788038978692, "agent": "explorer", "run": "c3a80f52", "seq": 12, "phase": "end", "tool": "Grep", "path": "src", "detail": "loadConfig"}
+{"ts": 1788038979518, "agent": "checker", "run": "b7e4d19c", "seq": 14, "phase": "end", "tool": "Read", "path": "src/config.ts", "detail": "read isProduction", "lines": [20, 22]}
+{"ts": 1788038985306, "agent": "implementer", "run": "a1f2c3d4", "seq": 15, "phase": "end", "tool": "Read", "path": "src/routes/users.ts", "detail": "read registerUserRoutes before wiring it", "lines": [7, 18]}
+{"ts": 1788038996368, "agent": "explorer", "run": "c3a80f52", "seq": 13, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "detail": "read the whole order module"}
+{"ts": 1788039003356, "agent": "checker", "run": "b7e4d19c", "seq": 15, "phase": "end", "tool": "Read", "path": "src/server.ts", "detail": "read main", "lines": [39, 46]}
+{"ts": 1788039009080, "agent": "implementer", "run": "a1f2c3d4", "seq": 16, "phase": "end", "tool": "Edit", "path": "src/routes/users.ts", "detail": "the user routes hand the limit through", "lines": [7, 18]}
+{"ts": 1788039019081, "agent": "implementer", "run": "a1f2c3d4", "seq": 17, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "tightened the input check of createUser again", "lines": [24, 30], "intent": "tightening the input check in createUser"}
+{"ts": 1788039036848, "agent": "checker", "run": "b7e4d19c", "seq": 16, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788039050309, "agent": "explorer", "run": "c3a80f52", "seq": 14, "phase": "end", "tool": "Grep", "path": "src", "detail": "validate"}
diff --git a/graph-ui/fixtures/agent-events/w11b-replay.jsonl b/graph-ui/fixtures/agent-events/w11b-replay.jsonl
new file mode 100644
index 000000000..1a1c68d17
--- /dev/null
+++ b/graph-ui/fixtures/agent-events/w11b-replay.jsonl
@@ -0,0 +1,73 @@
+{"ts": 1788139200000, "agent": "explorer", "run": "c3a80f52", "seq": 1, "phase": "end", "tool": "Grep", "path": "src", "detail": "validate"}
+{"ts": 1788139201200, "agent": "implementer", "run": "a1f2c3d4", "seq": 1, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "read createUser before touching it"}
+{"ts": 1788139202400, "agent": "planner", "run": "1b8d5f27", "seq": 1, "phase": "end", "tool": "Read", "path": "package.json", "detail": "read the scripts of the fixture repository"}
+{"ts": 1788139205000, "agent": "reviewer", "run": "d5b1e807", "seq": 1, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [17, 29], "detail": "read query"}
+{"ts": 1788139207000, "agent": "tracer", "run": "e9c72f16", "seq": 1, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [37, 53], "detail": "read UserEntity"}
+{"ts": 1788139209000, "agent": "indexer", "run": "f2d0a34b", "seq": 1, "phase": "end", "tool": "Glob", "path": "src", "detail": "loadConfig"}
+{"ts": 1788139211000, "agent": "packer", "run": "0a4e6c81", "seq": 1, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [11, 18], "detail": "read loadConfig"}
+{"ts": 1788139213000, "agent": "packer", "run": "0a4e6c81", "seq": 2, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [32, 37], "detail": "read createApp"}
+{"ts": 1788139216000, "agent": "explorer", "run": "c3a80f52", "seq": 2, "phase": "end", "tool": "Grep", "path": "src", "detail": "createUser"}
+{"ts": 1788139218000, "agent": "implementer", "run": "a1f2c3d4", "seq": 2, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [19, 31], "detail": "read validateUser"}
+{"ts": 1788139221000, "agent": "checker", "run": "b7e4d19c", "seq": 1, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788139224000, "agent": "reviewer", "run": "d5b1e807", "seq": 2, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [31, 35], "detail": "insert writes the payload it was handed"}
+{"ts": 1788139227000, "agent": "tracer", "run": "e9c72f16", "seq": 2, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "lines": [8, 26], "detail": "read getOrder"}
+{"ts": 1788139230000, "agent": "indexer", "run": "f2d0a34b", "seq": 2, "phase": "end", "tool": "Glob", "path": "src", "detail": "Router"}
+{"ts": 1788139233000, "agent": "packer", "run": "0a4e6c81", "seq": 3, "phase": "end", "tool": "Read", "path": "src/routes/users.ts", "lines": [7, 18], "detail": "read registerUserRoutes"}
+{"ts": 1788139236000, "agent": "reviewer", "run": "d5b1e807", "seq": 3, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [20, 22], "detail": "read isProduction"}
+{"ts": 1788139239000, "agent": "explorer", "run": "c3a80f52", "seq": 3, "phase": "end", "tool": "Read", "path": "src/routes/orders.ts", "lines": [6, 15], "detail": "read registerOrderRoutes"}
+{"ts": 1788139242000, "agent": "implementer", "run": "a1f2c3d4", "seq": 3, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [18, 21], "detail": "listUsers takes the limit it is given"}
+{"ts": 1788139245000, "agent": "tracer", "run": "e9c72f16", "seq": 3, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [39, 46], "detail": "read main"}
+{"ts": 1788139248000, "agent": "checker", "run": "b7e4d19c", "seq": 2, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788139251000, "agent": "reviewer", "run": "d5b1e807", "seq": 4, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [33, 38], "detail": "read validateId"}
+{"ts": 1788139254000, "agent": "tracer", "run": "e9c72f16", "seq": 4, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [58, 66], "detail": "read walk"}
+{"ts": 1788139257000, "agent": "indexer", "run": "f2d0a34b", "seq": 3, "phase": "end", "tool": "Glob", "path": "src", "detail": "insert"}
+{"ts": 1788139260000, "agent": "packer", "run": "0a4e6c81", "seq": 4, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [27, 30], "detail": "read User"}
+{"ts": 1788139260000, "agent": "planner", "run": "1b8d5f27", "seq": 2, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [32, 37], "detail": "read createApp"}
+{"ts": 1788139266000, "agent": "implementer", "run": "a1f2c3d4", "seq": 4, "phase": "end", "tool": "Edit", "path": "src/util/validate.ts", "lines": [19, 31], "detail": "validateUser now names the field it rejected"}
+{"ts": 1788139269000, "agent": "checker", "run": "b7e4d19c", "seq": 3, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [4, 8], "detail": "read Row"}
+{"ts": 1788139270000, "agent": "explorer", "run": "c3a80f52", "seq": 4, "phase": "end", "tool": "Grep", "path": "src", "detail": "Order"}
+{"ts": 1788139272000, "agent": "reviewer", "run": "d5b1e807", "seq": 5, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "lines": [30, 35], "detail": "read create"}
+{"ts": 1788139275000, "agent": "tracer", "run": "e9c72f16", "seq": 5, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [16, 30], "detail": "read SimpleRouter"}
+{"ts": 1788139278000, "agent": "indexer", "run": "f2d0a34b", "seq": 4, "phase": "end", "tool": "Glob", "path": "src", "detail": "query"}
+{"ts": 1788139281000, "agent": "checker", "run": "b7e4d19c", "seq": 4, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [4, 9], "detail": "read AppConfig"}
+{"ts": 1788139284000, "agent": "implementer", "run": "a1f2c3d4", "seq": 5, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [17, 29], "detail": "read query"}
+{"ts": 1788139286000, "agent": "implementer", "run": "a1f2c3d4", "seq": 6, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "lines": [8, 26], "detail": "read getOrder"}
+{"ts": 1788139287000, "agent": "checker", "run": "b7e4d19c", "seq": 5, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788139289000, "agent": "implementer", "run": "a1f2c3d4", "seq": 7, "phase": "end", "tool": "Edit", "path": "src/services/orderService.ts", "lines": [30, 35], "detail": "create now carries the customer id it was given"}
+{"ts": 1788139290000, "agent": "checker", "run": "b7e4d19c", "seq": 6, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [22, 25], "detail": "read Entity"}
+{"ts": 1788139292000, "agent": "implementer", "run": "a1f2c3d4", "seq": 8, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [37, 53], "detail": "read UserEntity"}
+{"ts": 1788139293000, "agent": "packer", "run": "0a4e6c81", "seq": 5, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "lines": [40, 42], "detail": "read create"}
+{"ts": 1788139295000, "agent": "implementer", "run": "a1f2c3d4", "seq": 9, "phase": "end", "tool": "Edit", "path": "src/util/validate.ts", "lines": [33, 38], "detail": "validateId rejects the empty string by name"}
+{"ts": 1788139296000, "agent": "reviewer", "run": "d5b1e807", "seq": 6, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "read hotspotScan"}
+{"ts": 1788139298000, "agent": "implementer", "run": "a1f2c3d4", "seq": 10, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [39, 46], "detail": "read main"}
+{"ts": 1788139299000, "agent": "tracer", "run": "e9c72f16", "seq": 6, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [17, 29], "detail": "read query"}
+{"ts": 1788139301000, "agent": "implementer", "run": "a1f2c3d4", "seq": 11, "phase": "end", "tool": "Edit", "path": "src/routes/users.ts", "lines": [7, 18], "detail": "the user routes hand the limit through"}
+{"ts": 1788139302000, "agent": "indexer", "run": "f2d0a34b", "seq": 5, "phase": "end", "tool": "Glob", "path": "src", "detail": "hotspot"}
+{"ts": 1788139304000, "agent": "implementer", "run": "a1f2c3d4", "seq": 12, "phase": "end", "tool": "Read", "path": "src/routes/orders.ts", "lines": [6, 15], "detail": "read registerOrderRoutes"}
+{"ts": 1788139305000, "agent": "tracer", "run": "e9c72f16", "seq": 7, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [11, 18], "detail": "read loadConfig"}
+{"ts": 1788139307000, "agent": "implementer", "run": "a1f2c3d4", "seq": 13, "phase": "end", "tool": "Edit", "path": "src/config.ts", "lines": [11, 18], "detail": "loadConfig reads the port it was given"}
+{"ts": 1788139308000, "agent": "checker", "run": "b7e4d19c", "seq": 7, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788139310000, "agent": "implementer", "run": "a1f2c3d4", "seq": 14, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [31, 35], "detail": "read insert"}
+{"ts": 1788139311000, "agent": "tracer", "run": "e9c72f16", "seq": 8, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [31, 35], "detail": "read insert"}
+{"ts": 1788139313000, "agent": "implementer", "run": "a1f2c3d4", "seq": 15, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "hotspotScan stops at the first needle it finds"}
+{"ts": 1788139314000, "agent": "indexer", "run": "f2d0a34b", "seq": 6, "phase": "end", "tool": "Glob", "path": "src", "detail": "walk"}
+{"ts": 1788139316000, "agent": "implementer", "run": "a1f2c3d4", "seq": 16, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "lines": [9, 16], "detail": "read toUser"}
+{"ts": 1788139317000, "agent": "indexer", "run": "f2d0a34b", "seq": 7, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [4, 12], "detail": "read ValidationError"}
+{"ts": 1788139319000, "agent": "implementer", "run": "a1f2c3d4", "seq": 17, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [18, 21], "detail": "listUsers takes the limit it is given"}
+{"ts": 1788139320000, "agent": "checker", "run": "b7e4d19c", "seq": 8, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [32, 35], "detail": "read Order"}
+{"ts": 1788139322000, "agent": "implementer", "run": "a1f2c3d4", "seq": 18, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [19, 31], "detail": "read validateUser"}
+{"ts": 1788139323000, "agent": "reviewer", "run": "d5b1e807", "seq": 7, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [58, 66], "detail": "walk returns the names it visited"}
+{"ts": 1788139325000, "agent": "implementer", "run": "a1f2c3d4", "seq": 19, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [58, 66], "detail": "read walk"}
+{"ts": 1788139326000, "agent": "tracer", "run": "e9c72f16", "seq": 9, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [4, 8], "detail": "read Row"}
+{"ts": 1788139329000, "agent": "indexer", "run": "f2d0a34b", "seq": 8, "phase": "end", "tool": "Glob", "path": "src", "detail": "Row"}
+{"ts": 1788139332000, "agent": "checker", "run": "b7e4d19c", "seq": 9, "phase": "end", "tool": "Grep", "path": "src", "detail": "insert"}
+{"ts": 1788139335000, "agent": "checker", "run": "b7e4d19c", "seq": 10, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
+{"ts": 1788139338000, "agent": "packer", "run": "0a4e6c81", "seq": 6, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [20, 22], "detail": "read isProduction"}
+{"ts": 1788139340000, "agent": "reviewer", "run": "d5b1e807", "seq": 8, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "hotspotScan keeps the first hit"}
+{"ts": 1788139340600, "agent": "indexer", "run": "f2d0a34b", "seq": 9, "phase": "end", "tool": "Glob", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "hotspotScan"}
+{"ts": 1788139341200, "agent": "tracer", "run": "e9c72f16", "seq": 10, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "read hotspotScan"}
+{"ts": 1788139343200, "agent": "implementer", "run": "a1f2c3d4", "seq": 20, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser", "intent": "tightening the input check in createUser"}
+{"ts": 1788139343650, "agent": "implementer", "run": "a1f2c3d4", "seq": 21, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
+{"ts": 1788139344100, "agent": "implementer", "run": "a1f2c3d4", "seq": 22, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
+{"ts": 1788139344550, "agent": "implementer", "run": "a1f2c3d4", "seq": 23, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
+{"ts": 1788139345000, "agent": "implementer", "run": "a1f2c3d4", "seq": 24, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
diff --git a/graph-ui/fixtures/atlas-sample-large/HERKUNFT.md b/graph-ui/fixtures/atlas-sample-large/HERKUNFT.md
new file mode 100644
index 000000000..a696c289f
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample-large/HERKUNFT.md
@@ -0,0 +1,10 @@
+# Large-source fixture provenance
+
+Generated for this frontend on 2026-08-28; no external code was copied.
+`src/big.ts` deterministically contains 100 small exported functions and `runAll`.
+`src/support.ts` and `src/index.ts` were written for the fixture.
+
+The 717-line source exceeds the 500-line `get_code_snippet` limit and exercises
+`source_clipped`. The reader must disclose missing source rather than presenting
+an incomplete file as complete. This corpus is separate from `atlas-sample` to
+preserve that fixture's graph counts. It is indexing input, not a runnable package.
diff --git a/graph-ui/fixtures/atlas-sample-large/package.json b/graph-ui/fixtures/atlas-sample-large/package.json
new file mode 100644
index 000000000..1d4f263b6
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample-large/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "atlas-sample-large",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Cap-Mess-Fixture fuer CodeAtlas. Never built, installed or executed by the monorepo.",
+ "main": "src/index.ts",
+ "license": "UNLICENSED"
+}
diff --git a/graph-ui/fixtures/atlas-sample-large/src/big.ts b/graph-ui/fixtures/atlas-sample-large/src/big.ts
new file mode 100644
index 000000000..25be226dd
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample-large/src/big.ts
@@ -0,0 +1,717 @@
+// Generierte Datei fuer die Cap-Messung des Readers (2026-08-28).
+// Deterministisch erzeugt: 100 kleine exportierte Funktionen, damit der
+// Indexer sie sauber frisst und die Datei sicher ueber 550 Zeilen liegt.
+// Der 500-Zeilen-Cap von get_code_snippet schneidet sie also mitten durch,
+// und genau das soll der Reader ehrlich benennen statt zu verschweigen.
+//
+// Von Hand aendern hat keinen Sinn: die Datei ist Messmaterial, kein Code,
+// der gelesen werden will.
+
+import { normalize, weigh } from './support';
+
+/** Schritt 1 des generierten Korpus. */
+export function step001(input: number): number {
+ const scaled = input * 1 + 7;
+ return normalize(scaled);
+}
+
+/** Schritt 2 des generierten Korpus. */
+export function step002(input: number): number {
+ const scaled = input * 2 + 1;
+ return weigh(scaled);
+}
+
+/** Schritt 3 des generierten Korpus. */
+export function step003(input: number): number {
+ const scaled = input * 3 + 8;
+ return normalize(scaled);
+}
+
+/** Schritt 4 des generierten Korpus. */
+export function step004(input: number): number {
+ const scaled = input * 4 + 2;
+ return weigh(scaled);
+}
+
+/** Schritt 5 des generierten Korpus. */
+export function step005(input: number): number {
+ const scaled = input * 5 + 9;
+ return normalize(scaled);
+}
+
+/** Schritt 6 des generierten Korpus. */
+export function step006(input: number): number {
+ const scaled = input * 6 + 3;
+ return weigh(scaled);
+}
+
+/** Schritt 7 des generierten Korpus. */
+export function step007(input: number): number {
+ const scaled = input * 7 + 10;
+ return normalize(scaled);
+}
+
+/** Schritt 8 des generierten Korpus. */
+export function step008(input: number): number {
+ const scaled = input * 8 + 4;
+ return weigh(scaled);
+}
+
+/** Schritt 9 des generierten Korpus. */
+export function step009(input: number): number {
+ const scaled = input * 9 + 11;
+ return normalize(scaled);
+}
+
+/** Schritt 10 des generierten Korpus. */
+export function step010(input: number): number {
+ const scaled = input * 10 + 5;
+ return weigh(scaled);
+}
+
+/** Schritt 11 des generierten Korpus. */
+export function step011(input: number): number {
+ const scaled = input * 11 + 12;
+ return normalize(scaled);
+}
+
+/** Schritt 12 des generierten Korpus. */
+export function step012(input: number): number {
+ const scaled = input * 12 + 6;
+ return weigh(scaled);
+}
+
+/** Schritt 13 des generierten Korpus. */
+export function step013(input: number): number {
+ const scaled = input * 13 + 0;
+ return normalize(scaled);
+}
+
+/** Schritt 14 des generierten Korpus. */
+export function step014(input: number): number {
+ const scaled = input * 14 + 7;
+ return weigh(scaled);
+}
+
+/** Schritt 15 des generierten Korpus. */
+export function step015(input: number): number {
+ const scaled = input * 15 + 1;
+ return normalize(scaled);
+}
+
+/** Schritt 16 des generierten Korpus. */
+export function step016(input: number): number {
+ const scaled = input * 16 + 8;
+ return weigh(scaled);
+}
+
+/** Schritt 17 des generierten Korpus. */
+export function step017(input: number): number {
+ const scaled = input * 17 + 2;
+ return normalize(scaled);
+}
+
+/** Schritt 18 des generierten Korpus. */
+export function step018(input: number): number {
+ const scaled = input * 18 + 9;
+ return weigh(scaled);
+}
+
+/** Schritt 19 des generierten Korpus. */
+export function step019(input: number): number {
+ const scaled = input * 19 + 3;
+ return normalize(scaled);
+}
+
+/** Schritt 20 des generierten Korpus. */
+export function step020(input: number): number {
+ const scaled = input * 20 + 10;
+ return weigh(scaled);
+}
+
+/** Schritt 21 des generierten Korpus. */
+export function step021(input: number): number {
+ const scaled = input * 21 + 4;
+ return normalize(scaled);
+}
+
+/** Schritt 22 des generierten Korpus. */
+export function step022(input: number): number {
+ const scaled = input * 22 + 11;
+ return weigh(scaled);
+}
+
+/** Schritt 23 des generierten Korpus. */
+export function step023(input: number): number {
+ const scaled = input * 23 + 5;
+ return normalize(scaled);
+}
+
+/** Schritt 24 des generierten Korpus. */
+export function step024(input: number): number {
+ const scaled = input * 24 + 12;
+ return weigh(scaled);
+}
+
+/** Schritt 25 des generierten Korpus. */
+export function step025(input: number): number {
+ const scaled = input * 25 + 6;
+ return normalize(scaled);
+}
+
+/** Schritt 26 des generierten Korpus. */
+export function step026(input: number): number {
+ const scaled = input * 26 + 0;
+ return weigh(scaled);
+}
+
+/** Schritt 27 des generierten Korpus. */
+export function step027(input: number): number {
+ const scaled = input * 27 + 7;
+ return normalize(scaled);
+}
+
+/** Schritt 28 des generierten Korpus. */
+export function step028(input: number): number {
+ const scaled = input * 28 + 1;
+ return weigh(scaled);
+}
+
+/** Schritt 29 des generierten Korpus. */
+export function step029(input: number): number {
+ const scaled = input * 29 + 8;
+ return normalize(scaled);
+}
+
+/** Schritt 30 des generierten Korpus. */
+export function step030(input: number): number {
+ const scaled = input * 30 + 2;
+ return weigh(scaled);
+}
+
+/** Schritt 31 des generierten Korpus. */
+export function step031(input: number): number {
+ const scaled = input * 31 + 9;
+ return normalize(scaled);
+}
+
+/** Schritt 32 des generierten Korpus. */
+export function step032(input: number): number {
+ const scaled = input * 32 + 3;
+ return weigh(scaled);
+}
+
+/** Schritt 33 des generierten Korpus. */
+export function step033(input: number): number {
+ const scaled = input * 33 + 10;
+ return normalize(scaled);
+}
+
+/** Schritt 34 des generierten Korpus. */
+export function step034(input: number): number {
+ const scaled = input * 34 + 4;
+ return weigh(scaled);
+}
+
+/** Schritt 35 des generierten Korpus. */
+export function step035(input: number): number {
+ const scaled = input * 35 + 11;
+ return normalize(scaled);
+}
+
+/** Schritt 36 des generierten Korpus. */
+export function step036(input: number): number {
+ const scaled = input * 36 + 5;
+ return weigh(scaled);
+}
+
+/** Schritt 37 des generierten Korpus. */
+export function step037(input: number): number {
+ const scaled = input * 37 + 12;
+ return normalize(scaled);
+}
+
+/** Schritt 38 des generierten Korpus. */
+export function step038(input: number): number {
+ const scaled = input * 38 + 6;
+ return weigh(scaled);
+}
+
+/** Schritt 39 des generierten Korpus. */
+export function step039(input: number): number {
+ const scaled = input * 39 + 0;
+ return normalize(scaled);
+}
+
+/** Schritt 40 des generierten Korpus. */
+export function step040(input: number): number {
+ const scaled = input * 40 + 7;
+ return weigh(scaled);
+}
+
+/** Schritt 41 des generierten Korpus. */
+export function step041(input: number): number {
+ const scaled = input * 41 + 1;
+ return normalize(scaled);
+}
+
+/** Schritt 42 des generierten Korpus. */
+export function step042(input: number): number {
+ const scaled = input * 42 + 8;
+ return weigh(scaled);
+}
+
+/** Schritt 43 des generierten Korpus. */
+export function step043(input: number): number {
+ const scaled = input * 43 + 2;
+ return normalize(scaled);
+}
+
+/** Schritt 44 des generierten Korpus. */
+export function step044(input: number): number {
+ const scaled = input * 44 + 9;
+ return weigh(scaled);
+}
+
+/** Schritt 45 des generierten Korpus. */
+export function step045(input: number): number {
+ const scaled = input * 45 + 3;
+ return normalize(scaled);
+}
+
+/** Schritt 46 des generierten Korpus. */
+export function step046(input: number): number {
+ const scaled = input * 46 + 10;
+ return weigh(scaled);
+}
+
+/** Schritt 47 des generierten Korpus. */
+export function step047(input: number): number {
+ const scaled = input * 47 + 4;
+ return normalize(scaled);
+}
+
+/** Schritt 48 des generierten Korpus. */
+export function step048(input: number): number {
+ const scaled = input * 48 + 11;
+ return weigh(scaled);
+}
+
+/** Schritt 49 des generierten Korpus. */
+export function step049(input: number): number {
+ const scaled = input * 49 + 5;
+ return normalize(scaled);
+}
+
+/** Schritt 50 des generierten Korpus. */
+export function step050(input: number): number {
+ const scaled = input * 50 + 12;
+ return weigh(scaled);
+}
+
+/** Schritt 51 des generierten Korpus. */
+export function step051(input: number): number {
+ const scaled = input * 51 + 6;
+ return normalize(scaled);
+}
+
+/** Schritt 52 des generierten Korpus. */
+export function step052(input: number): number {
+ const scaled = input * 52 + 0;
+ return weigh(scaled);
+}
+
+/** Schritt 53 des generierten Korpus. */
+export function step053(input: number): number {
+ const scaled = input * 53 + 7;
+ return normalize(scaled);
+}
+
+/** Schritt 54 des generierten Korpus. */
+export function step054(input: number): number {
+ const scaled = input * 54 + 1;
+ return weigh(scaled);
+}
+
+/** Schritt 55 des generierten Korpus. */
+export function step055(input: number): number {
+ const scaled = input * 55 + 8;
+ return normalize(scaled);
+}
+
+/** Schritt 56 des generierten Korpus. */
+export function step056(input: number): number {
+ const scaled = input * 56 + 2;
+ return weigh(scaled);
+}
+
+/** Schritt 57 des generierten Korpus. */
+export function step057(input: number): number {
+ const scaled = input * 57 + 9;
+ return normalize(scaled);
+}
+
+/** Schritt 58 des generierten Korpus. */
+export function step058(input: number): number {
+ const scaled = input * 58 + 3;
+ return weigh(scaled);
+}
+
+/** Schritt 59 des generierten Korpus. */
+export function step059(input: number): number {
+ const scaled = input * 59 + 10;
+ return normalize(scaled);
+}
+
+/** Schritt 60 des generierten Korpus. */
+export function step060(input: number): number {
+ const scaled = input * 60 + 4;
+ return weigh(scaled);
+}
+
+/** Schritt 61 des generierten Korpus. */
+export function step061(input: number): number {
+ const scaled = input * 61 + 11;
+ return normalize(scaled);
+}
+
+/** Schritt 62 des generierten Korpus. */
+export function step062(input: number): number {
+ const scaled = input * 62 + 5;
+ return weigh(scaled);
+}
+
+/** Schritt 63 des generierten Korpus. */
+export function step063(input: number): number {
+ const scaled = input * 63 + 12;
+ return normalize(scaled);
+}
+
+/** Schritt 64 des generierten Korpus. */
+export function step064(input: number): number {
+ const scaled = input * 64 + 6;
+ return weigh(scaled);
+}
+
+/** Schritt 65 des generierten Korpus. */
+export function step065(input: number): number {
+ const scaled = input * 65 + 0;
+ return normalize(scaled);
+}
+
+/** Schritt 66 des generierten Korpus. */
+export function step066(input: number): number {
+ const scaled = input * 66 + 7;
+ return weigh(scaled);
+}
+
+/** Schritt 67 des generierten Korpus. */
+export function step067(input: number): number {
+ const scaled = input * 67 + 1;
+ return normalize(scaled);
+}
+
+/** Schritt 68 des generierten Korpus. */
+export function step068(input: number): number {
+ const scaled = input * 68 + 8;
+ return weigh(scaled);
+}
+
+/** Schritt 69 des generierten Korpus. */
+export function step069(input: number): number {
+ const scaled = input * 69 + 2;
+ return normalize(scaled);
+}
+
+/** Schritt 70 des generierten Korpus. */
+export function step070(input: number): number {
+ const scaled = input * 70 + 9;
+ return weigh(scaled);
+}
+
+/** Schritt 71 des generierten Korpus. */
+export function step071(input: number): number {
+ const scaled = input * 71 + 3;
+ return normalize(scaled);
+}
+
+/** Schritt 72 des generierten Korpus. */
+export function step072(input: number): number {
+ const scaled = input * 72 + 10;
+ return weigh(scaled);
+}
+
+/** Schritt 73 des generierten Korpus. */
+export function step073(input: number): number {
+ const scaled = input * 73 + 4;
+ return normalize(scaled);
+}
+
+/** Schritt 74 des generierten Korpus. */
+export function step074(input: number): number {
+ const scaled = input * 74 + 11;
+ return weigh(scaled);
+}
+
+/** Schritt 75 des generierten Korpus. */
+export function step075(input: number): number {
+ const scaled = input * 75 + 5;
+ return normalize(scaled);
+}
+
+/** Schritt 76 des generierten Korpus. */
+export function step076(input: number): number {
+ const scaled = input * 76 + 12;
+ return weigh(scaled);
+}
+
+/** Schritt 77 des generierten Korpus. */
+export function step077(input: number): number {
+ const scaled = input * 77 + 6;
+ return normalize(scaled);
+}
+
+/** Schritt 78 des generierten Korpus. */
+export function step078(input: number): number {
+ const scaled = input * 78 + 0;
+ return weigh(scaled);
+}
+
+/** Schritt 79 des generierten Korpus. */
+export function step079(input: number): number {
+ const scaled = input * 79 + 7;
+ return normalize(scaled);
+}
+
+/** Schritt 80 des generierten Korpus. */
+export function step080(input: number): number {
+ const scaled = input * 80 + 1;
+ return weigh(scaled);
+}
+
+/** Schritt 81 des generierten Korpus. */
+export function step081(input: number): number {
+ const scaled = input * 81 + 8;
+ return normalize(scaled);
+}
+
+/** Schritt 82 des generierten Korpus. */
+export function step082(input: number): number {
+ const scaled = input * 82 + 2;
+ return weigh(scaled);
+}
+
+/** Schritt 83 des generierten Korpus. */
+export function step083(input: number): number {
+ const scaled = input * 83 + 9;
+ return normalize(scaled);
+}
+
+/** Schritt 84 des generierten Korpus. */
+export function step084(input: number): number {
+ const scaled = input * 84 + 3;
+ return weigh(scaled);
+}
+
+/** Schritt 85 des generierten Korpus. */
+export function step085(input: number): number {
+ const scaled = input * 85 + 10;
+ return normalize(scaled);
+}
+
+/** Schritt 86 des generierten Korpus. */
+export function step086(input: number): number {
+ const scaled = input * 86 + 4;
+ return weigh(scaled);
+}
+
+/** Schritt 87 des generierten Korpus. */
+export function step087(input: number): number {
+ const scaled = input * 87 + 11;
+ return normalize(scaled);
+}
+
+/** Schritt 88 des generierten Korpus. */
+export function step088(input: number): number {
+ const scaled = input * 88 + 5;
+ return weigh(scaled);
+}
+
+/** Schritt 89 des generierten Korpus. */
+export function step089(input: number): number {
+ const scaled = input * 89 + 12;
+ return normalize(scaled);
+}
+
+/** Schritt 90 des generierten Korpus. */
+export function step090(input: number): number {
+ const scaled = input * 90 + 6;
+ return weigh(scaled);
+}
+
+/** Schritt 91 des generierten Korpus. */
+export function step091(input: number): number {
+ const scaled = input * 91 + 0;
+ return normalize(scaled);
+}
+
+/** Schritt 92 des generierten Korpus. */
+export function step092(input: number): number {
+ const scaled = input * 92 + 7;
+ return weigh(scaled);
+}
+
+/** Schritt 93 des generierten Korpus. */
+export function step093(input: number): number {
+ const scaled = input * 93 + 1;
+ return normalize(scaled);
+}
+
+/** Schritt 94 des generierten Korpus. */
+export function step094(input: number): number {
+ const scaled = input * 94 + 8;
+ return weigh(scaled);
+}
+
+/** Schritt 95 des generierten Korpus. */
+export function step095(input: number): number {
+ const scaled = input * 95 + 2;
+ return normalize(scaled);
+}
+
+/** Schritt 96 des generierten Korpus. */
+export function step096(input: number): number {
+ const scaled = input * 96 + 9;
+ return weigh(scaled);
+}
+
+/** Schritt 97 des generierten Korpus. */
+export function step097(input: number): number {
+ const scaled = input * 97 + 3;
+ return normalize(scaled);
+}
+
+/** Schritt 98 des generierten Korpus. */
+export function step098(input: number): number {
+ const scaled = input * 98 + 10;
+ return weigh(scaled);
+}
+
+/** Schritt 99 des generierten Korpus. */
+export function step099(input: number): number {
+ const scaled = input * 99 + 4;
+ return normalize(scaled);
+}
+
+/** Schritt 100 des generierten Korpus. */
+export function step100(input: number): number {
+ const scaled = input * 100 + 11;
+ return weigh(scaled);
+}
+
+/** Alle Schritte der Reihe nach, damit die Datei einen Einstiegspunkt hat. */
+export function runAll(seed: number): number {
+ const steps = [
+ step001,
+ step002,
+ step003,
+ step004,
+ step005,
+ step006,
+ step007,
+ step008,
+ step009,
+ step010,
+ step011,
+ step012,
+ step013,
+ step014,
+ step015,
+ step016,
+ step017,
+ step018,
+ step019,
+ step020,
+ step021,
+ step022,
+ step023,
+ step024,
+ step025,
+ step026,
+ step027,
+ step028,
+ step029,
+ step030,
+ step031,
+ step032,
+ step033,
+ step034,
+ step035,
+ step036,
+ step037,
+ step038,
+ step039,
+ step040,
+ step041,
+ step042,
+ step043,
+ step044,
+ step045,
+ step046,
+ step047,
+ step048,
+ step049,
+ step050,
+ step051,
+ step052,
+ step053,
+ step054,
+ step055,
+ step056,
+ step057,
+ step058,
+ step059,
+ step060,
+ step061,
+ step062,
+ step063,
+ step064,
+ step065,
+ step066,
+ step067,
+ step068,
+ step069,
+ step070,
+ step071,
+ step072,
+ step073,
+ step074,
+ step075,
+ step076,
+ step077,
+ step078,
+ step079,
+ step080,
+ step081,
+ step082,
+ step083,
+ step084,
+ step085,
+ step086,
+ step087,
+ step088,
+ step089,
+ step090,
+ step091,
+ step092,
+ step093,
+ step094,
+ step095,
+ step096,
+ step097,
+ step098,
+ step099,
+ step100,
+ ];
+ return steps.reduce((acc, step) => step(acc), seed);
+}
diff --git a/graph-ui/fixtures/atlas-sample-large/src/index.ts b/graph-ui/fixtures/atlas-sample-large/src/index.ts
new file mode 100644
index 000000000..ffa80bb68
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample-large/src/index.ts
@@ -0,0 +1,11 @@
+// Einstiegspunkt des Mess-Korpus. Er tut absichtlich fast nichts: gemessen
+// wird der Reader an big.ts, nicht dieses Projekt.
+
+import { runAll, step001 } from './big';
+import { normalize } from './support';
+
+/** Faehrt den generierten Korpus einmal durch und gibt das Ergebnis zurueck. */
+export function main(seed = 1): number {
+ const first = step001(seed);
+ return normalize(runAll(first));
+}
diff --git a/graph-ui/fixtures/atlas-sample-large/src/support.ts b/graph-ui/fixtures/atlas-sample-large/src/support.ts
new file mode 100644
index 000000000..f7f3f4613
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample-large/src/support.ts
@@ -0,0 +1,16 @@
+// Zwei Hilfsfunktionen, die big.ts aufruft. Sie sind hier, damit die grosse
+// Datei nicht beziehungslos im Graphen haengt: der Indexer sieht Importe und
+// Aufrufkanten und der Reader hat neben big.ts noch etwas Kleines zu zeigen.
+
+/** Haelt einen Wert in einem festen Fenster, damit runAll nicht davonlaeuft. */
+export function normalize(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+ return ((value % 997) + 997) % 997;
+}
+
+/** Gewichtet einen Wert und normalisiert ihn danach. */
+export function weigh(value: number): number {
+ return normalize(Math.trunc(value / 3) + 11);
+}
diff --git a/graph-ui/fixtures/atlas-sample-large/tsconfig.json b/graph-ui/fixtures/atlas-sample-large/tsconfig.json
new file mode 100644
index 000000000..2d2d5f15b
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample-large/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "moduleResolution": "node",
+ "lib": ["ES2022"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "rootDir": ".",
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/graph-ui/fixtures/atlas-sample/HERKUNFT.md b/graph-ui/fixtures/atlas-sample/HERKUNFT.md
new file mode 100644
index 000000000..c79f397c8
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/HERKUNFT.md
@@ -0,0 +1,8 @@
+# Sample fixture provenance
+
+Copied from Bernhard Jackiewicz's CodeAtlasIDE project on 2026-08-28,
+`fixtures/atlas-sample`, by the same author.
+
+The twelve TypeScript and configuration files form a deliberately small, stable
+demo and test corpus. Graph assertions depend on that stable source. Changes here
+apply only to this fixture; they do not update its source project.
diff --git a/graph-ui/fixtures/atlas-sample/package.json b/graph-ui/fixtures/atlas-sample/package.json
new file mode 100644
index 000000000..1ff41f48c
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "atlas-sample",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Indexing fixture for CodeAtlas. Never built, installed or executed by the monorepo.",
+ "main": "src/server.ts",
+ "license": "UNLICENSED"
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/config.ts b/graph-ui/fixtures/atlas-sample/src/config.ts
new file mode 100644
index 000000000..595e02545
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/config.ts
@@ -0,0 +1,22 @@
+// Configuration loader. Every value comes from the environment, which gives
+// the fixture a stable set of environment read sites for the graph.
+
+export interface AppConfig {
+ host: string;
+ port: number;
+ databaseUrl: string;
+ logLevel: string;
+}
+
+export function loadConfig(): AppConfig {
+ return {
+ host: process.env.HOST ?? '127.0.0.1',
+ port: Number(process.env.PORT ?? '8080'),
+ databaseUrl: process.env.DB_URL ?? 'memory://atlas-sample',
+ logLevel: process.env.LOG_LEVEL ?? 'info',
+ };
+}
+
+export function isProduction(): boolean {
+ return process.env.NODE_ENV === 'production';
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/repo/db.ts b/graph-ui/fixtures/atlas-sample/src/repo/db.ts
new file mode 100644
index 000000000..b2ade6588
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/repo/db.ts
@@ -0,0 +1,66 @@
+// In-memory storage stand-in. No driver and no network: the fixture only needs
+// call sites and control flow that an indexer can see.
+
+export interface Row {
+ id: string;
+ table: string;
+ payload: Record;
+}
+
+export interface TreeNode {
+ name: string;
+ children: TreeNode[];
+}
+
+const rows: Row[] = [];
+
+export function query(table: string, limit: number): Row[] {
+ const matches: Row[] = [];
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ if (row.table === table) {
+ matches.push(row);
+ }
+ if (matches.length >= limit) {
+ break;
+ }
+ }
+ return matches;
+}
+
+export function insert(table: string, id: string, payload: Record): Row {
+ const row: Row = { id, table, payload };
+ rows.push(row);
+ return row;
+}
+
+// Deliberate hotspot: three nested loops with a linear lookup in the innermost
+// body. The complexity view is expected to flag this function.
+export function hotspotScan(tables: string[], keys: string[], needles: string[]): Row[] {
+ const found: Row[] = [];
+ for (const table of tables) {
+ for (const key of keys) {
+ for (const needle of needles) {
+ const candidates = query(table, 1000);
+ const position = candidates.map((row) => row.id).indexOf(needle);
+ const hit = candidates.find((row) => String(row.payload[key]) === needle);
+ if (position >= 0 && hit) {
+ found.push(hit);
+ }
+ }
+ }
+ }
+ return found;
+}
+
+// Deliberately unguarded recursion: walk has no base case of its own and
+// relies on the child list running out.
+export function walk(node: TreeNode): string[] {
+ const names: string[] = [node.name];
+ for (const child of node.children) {
+ for (const name of walk(child)) {
+ names.push(name);
+ }
+ }
+ return names;
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/routes/orders.ts b/graph-ui/fixtures/atlas-sample/src/routes/orders.ts
new file mode 100644
index 000000000..f220082f3
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/routes/orders.ts
@@ -0,0 +1,17 @@
+// Order routes. A single parameterised lookup that delegates to the service.
+
+import { getOrder } from '../services/orderService';
+// Deliberately unused harness fixture: proves the file-level import finding.
+import { insert } from '../repo/db';
+import type { HttpRequest, HttpResponse, Router } from '../types';
+
+export function registerOrderRoutes(router: Router): void {
+ router.get('/orders/:id', (req: HttpRequest, res: HttpResponse) => {
+ const order = getOrder(req.params.id);
+ if (!order) {
+ res.status(404).json({ error: 'order not found' });
+ return;
+ }
+ res.json({ order });
+ });
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/routes/users.ts b/graph-ui/fixtures/atlas-sample/src/routes/users.ts
new file mode 100644
index 000000000..32f010d6a
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/routes/users.ts
@@ -0,0 +1,18 @@
+// User routes. Handlers stay thin so the call edge from the route to the
+// service is the only thing the graph has to resolve.
+
+import { createUser, listUsers } from '../services/userService';
+import type { HttpRequest, HttpResponse, Router } from '../types';
+
+export function registerUserRoutes(router: Router): void {
+ router.get('/users', (req: HttpRequest, res: HttpResponse) => {
+ const limit = Number(req.query.limit ?? '50');
+ const users = listUsers(limit);
+ res.json({ users, count: users.length });
+ });
+
+ router.post('/users', (req: HttpRequest, res: HttpResponse) => {
+ const user = createUser(req.body);
+ res.status(201).json({ user });
+ });
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/server.ts b/graph-ui/fixtures/atlas-sample/src/server.ts
new file mode 100644
index 000000000..cf5236cd6
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/server.ts
@@ -0,0 +1,48 @@
+// Entry point of the atlas-sample fixture. Builds the router, registers the
+// route modules and prints the routing table. Nothing here opens a socket:
+// the fixture is indexed, never executed.
+
+import { loadConfig } from './config';
+import { registerOrderRoutes } from './routes/orders';
+import { registerUserRoutes } from './routes/users';
+import type { RouteHandler, Router } from './types';
+
+interface Route {
+ method: string;
+ path: string;
+ handler: RouteHandler;
+}
+
+export class SimpleRouter implements Router {
+ private readonly routes: Route[] = [];
+
+ get(path: string, handler: RouteHandler): void {
+ this.routes.push({ method: 'GET', path, handler });
+ }
+
+ post(path: string, handler: RouteHandler): void {
+ this.routes.push({ method: 'POST', path, handler });
+ }
+
+ table(): string[] {
+ return this.routes.map((route) => `${route.method} ${route.path}`);
+ }
+}
+
+export function createApp(): SimpleRouter {
+ const router = new SimpleRouter();
+ registerUserRoutes(router);
+ registerOrderRoutes(router);
+ return router;
+}
+
+export function main(): void {
+ const config = loadConfig();
+ const app = createApp();
+ for (const entry of app.table()) {
+ console.log(`[atlas-sample] route ${entry}`);
+ }
+ console.log(`[atlas-sample] would listen on ${config.host}:${config.port}`);
+}
+
+main();
diff --git a/graph-ui/fixtures/atlas-sample/src/services/orderService.ts b/graph-ui/fixtures/atlas-sample/src/services/orderService.ts
new file mode 100644
index 000000000..daa7f4bdd
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/services/orderService.ts
@@ -0,0 +1,35 @@
+// Order use cases. getOrder wraps the repository call in try/catch so the
+// fixture has a recoverable failure path to render.
+
+import { insert, query } from '../repo/db';
+import type { Order } from '../types';
+import { validateId } from '../util/validate';
+
+export function getOrder(id: string): Order | undefined {
+ try {
+ const key = validateId(id);
+ const rows = query('orders', 100);
+ const row = rows.find((candidate) => candidate.id === key);
+ if (!row) {
+ return undefined;
+ }
+ return {
+ id: row.id,
+ createdAt: String(row.payload.createdAt ?? ''),
+ customerId: String(row.payload.customerId ?? ''),
+ total: Number(row.payload.total ?? 0),
+ };
+ } catch (error) {
+ console.error(`[orders] lookup failed for ${id}: ${String(error)}`);
+ return undefined;
+ }
+}
+
+// Same bare name as userService.create on purpose: the pair is the ambiguity
+// fixture for path tracing.
+export function create(customerId: string, total: number): Order {
+ const createdAt = new Date(0).toISOString();
+ const id = `order-${customerId}-${total}`;
+ insert('orders', id, { customerId, total, createdAt });
+ return { id, createdAt, customerId, total };
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/services/userService.ts b/graph-ui/fixtures/atlas-sample/src/services/userService.ts
new file mode 100644
index 000000000..d71b8b85d
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/services/userService.ts
@@ -0,0 +1,42 @@
+// User use cases. Kept small on purpose so the facts the graph reports for
+// createUser stay stable: it calls, throws and reads exactly one thing each.
+
+import { insert, query, Row } from '../repo/db';
+import { UserEntity } from '../types';
+import type { User } from '../types';
+import { ValidationError, validateUser } from '../util/validate';
+
+function toUser(row: Row): User {
+ return {
+ id: row.id,
+ createdAt: String(row.payload.createdAt ?? ''),
+ email: String(row.payload.email ?? ''),
+ name: String(row.payload.name ?? ''),
+ };
+}
+
+export function listUsers(limit = 50): User[] {
+ const rows = query('users', limit);
+ return rows.map(toUser);
+}
+
+export function createUser(input: unknown): User {
+ const parsed = validateUser(input);
+ const dsn = process.env.DB_URL ?? '';
+ if (dsn.length === 0) {
+ throw new ValidationError('DB_URL', 'DB_URL must be set before writing users');
+ }
+ const entity = new UserEntity(`user-${listUsers().length + 1}`, parsed.email, parsed.name);
+ const row = insert('users', entity.id, {
+ email: entity.email,
+ name: entity.name,
+ createdAt: entity.createdAt,
+ });
+ return toUser(row);
+}
+
+// Thin alias kept on purpose: orderService exports a function with the same
+// bare name, which makes "create" an ambiguous trace target.
+export function create(input: unknown): User {
+ return createUser(input);
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/types.ts b/graph-ui/fixtures/atlas-sample/src/types.ts
new file mode 100644
index 000000000..c60333353
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/types.ts
@@ -0,0 +1,53 @@
+// Shared types for the atlas-sample fixture: a small express-like HTTP surface
+// plus the domain entities the services work with. No runtime dependencies.
+
+export interface HttpRequest {
+ params: Record;
+ query: Record;
+ body: unknown;
+}
+
+export interface HttpResponse {
+ status(code: number): HttpResponse;
+ json(payload: unknown): void;
+}
+
+export type RouteHandler = (req: HttpRequest, res: HttpResponse) => void;
+
+export interface Router {
+ get(path: string, handler: RouteHandler): void;
+ post(path: string, handler: RouteHandler): void;
+}
+
+export interface Entity {
+ id: string;
+ createdAt: string;
+}
+
+export interface User extends Entity {
+ email: string;
+ name: string;
+}
+
+export interface Order extends Entity {
+ customerId: string;
+ total: number;
+}
+
+export class UserEntity implements Entity {
+ readonly id: string;
+ readonly createdAt: string;
+ readonly email: string;
+ readonly name: string;
+
+ constructor(id: string, email: string, name: string) {
+ this.id = id;
+ this.email = email;
+ this.name = name;
+ this.createdAt = new Date(0).toISOString();
+ }
+
+ label(): string {
+ return `${this.name} <${this.email}>`;
+ }
+}
diff --git a/graph-ui/fixtures/atlas-sample/src/util/validate.ts b/graph-ui/fixtures/atlas-sample/src/util/validate.ts
new file mode 100644
index 000000000..ba130d0d2
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/src/util/validate.ts
@@ -0,0 +1,38 @@
+// Input validation. ValidationError lives here so both services throw the
+// same class and the graph has a single error type to point at.
+
+export class ValidationError extends Error {
+ readonly field: string;
+
+ constructor(field: string, message: string) {
+ super(message);
+ this.name = 'ValidationError';
+ this.field = field;
+ }
+}
+
+export interface UserInput {
+ email: string;
+ name: string;
+}
+
+export function validateUser(input: unknown): UserInput {
+ const candidate = input as Partial | null;
+ if (!candidate || typeof candidate.email !== 'string') {
+ throw new ValidationError('email', 'email must be a string');
+ }
+ if (!candidate.email.includes('@')) {
+ throw new ValidationError('email', 'email must contain an at sign');
+ }
+ if (typeof candidate.name !== 'string' || candidate.name.length === 0) {
+ throw new ValidationError('name', 'name must be a non-empty string');
+ }
+ return { email: candidate.email, name: candidate.name };
+}
+
+export function validateId(value: string): string {
+ if (value.trim().length === 0) {
+ throw new ValidationError('id', 'id must not be empty');
+ }
+ return value;
+}
diff --git a/graph-ui/fixtures/atlas-sample/test/userService.test.ts b/graph-ui/fixtures/atlas-sample/test/userService.test.ts
new file mode 100644
index 000000000..a9904d037
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/test/userService.test.ts
@@ -0,0 +1,16 @@
+// Minimal fixture test. It exercises listUsers and nothing else, so the graph
+// is expected to show a test edge for that function only.
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { listUsers } from '../src/services/userService';
+
+test('listUsers returns an array', () => {
+ const users = listUsers();
+ assert.ok(Array.isArray(users));
+});
+
+test('listUsers honours the limit', () => {
+ const users = listUsers(1);
+ assert.ok(users.length <= 1);
+});
diff --git a/graph-ui/fixtures/atlas-sample/tsconfig.json b/graph-ui/fixtures/atlas-sample/tsconfig.json
new file mode 100644
index 000000000..6faf3fc9e
--- /dev/null
+++ b/graph-ui/fixtures/atlas-sample/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "moduleResolution": "node",
+ "lib": ["ES2022"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "rootDir": ".",
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"]
+}
diff --git a/graph-ui/index.html b/graph-ui/index.html
index e20a6503b..7e195a941 100644
--- a/graph-ui/index.html
+++ b/graph-ui/index.html
@@ -1,11 +1,13 @@
-
+
- Codebase Memory — Graph
+
+
+ CBM Lens Beta
-
+
diff --git a/graph-ui/llm/fetch-model.sh b/graph-ui/llm/fetch-model.sh
new file mode 100644
index 000000000..594d72776
--- /dev/null
+++ b/graph-ui/llm/fetch-model.sh
@@ -0,0 +1,134 @@
+#!/bin/sh
+# Ein Modell von Hugging Face in das Cache-Verzeichnis dieses Projekts holen.
+#
+# Aufruf:
+# llm/fetch-model.sh /[:] holt das Modell
+# llm/fetch-model.sh --print / zeigt nur den Befehl
+# llm/fetch-model.sh --list zeigt, was im Cache liegt
+#
+# ## Warum dieses Skript und nicht ein Knopf in der Oberflaeche
+#
+# Die Oberflaeche ist eine SPA ohne eigenes Backend. Sie startet keinen Prozess
+# und schreibt nichts auf die Platte, also kann sie auch nichts herunterladen.
+# Was sie kann, tut sie: sie zeigt die Vorschlaege mit ihren gemessenen Zahlen
+# und den fertigen Aufruf zum Kopieren. Der Aufruf ist dieser hier.
+#
+# ## Was hier wirklich passiert
+#
+# llama-server kann selbst laden: `-hf user/repo[:quant]` holt die Datei von
+# huggingface.co in das Verzeichnis, das llama.cpp als Cache liest
+# (Umgebungsvariable LLAMA_CACHE, ohne sie auf macOS $HOME/Library/Caches/
+# llama.cpp). Dieses Skript setzt LLAMA_CACHE auf das Cache-Verzeichnis dieses
+# Projekts, damit ein geholtes Modell in derselben Liste auftaucht wie die schon
+# vorhandenen und llm/start.sh es ohne weitere Angabe findet.
+#
+# Der Quant ist optional; ohne ihn nimmt llama-server Q4_K_M, und wenn es den
+# nicht gibt, die erste Datei des Repositories. Er wird ohne Ansehen der
+# Gross- und Kleinschreibung gesucht.
+#
+# ## Der eine Satz, der vor jedem Lauf dasteht
+#
+# Dieser Befehl GEHT INS NETZ. Er ist damit die eine Stelle, an der dieses
+# ansonsten abgeschottete Produkt eine fremde Adresse anspricht, und er sagt das
+# vorher, samt Ziel und Ablageort. Ein Beweislauf fuehrt ihn nicht aus.
+
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "$HERE/.." && pwd)
+SERVER="$ROOT/vendor/llama/llama-server"
+
+# Dasselbe Verzeichnis wie in llm/start.sh, mit derselben Reihenfolge. Zwei
+# Vorgaben fuer denselben Ort waeren ein geholtes Modell, das in der Liste des
+# Starters nicht auftaucht.
+MODELS_DIR=${ATLAS_MODELS_DIR:-${LLAMA_CACHE:-$ROOT/models}}
+
+usage() {
+ echo "Aufruf: llm/fetch-model.sh /[:]" >&2
+ echo " llm/fetch-model.sh --print /[:]" >&2
+ echo " llm/fetch-model.sh --list" >&2
+ echo "" >&2
+ echo "Beispiele (die sechs gemessenen Kandidaten stehen in docs/adr/0001-modellwahl.md):" >&2
+ echo " llm/fetch-model.sh unsloth/Qwen3.5-2B-GGUF" >&2
+ echo " llm/fetch-model.sh LiquidAI/LFM2.5-1.2B-Instruct-GGUF:Q4_K_M" >&2
+ echo "" >&2
+ echo "Cache-Verzeichnis: $MODELS_DIR (ueber ATLAS_MODELS_DIR aenderbar)" >&2
+}
+
+PRINT_ONLY=0
+case "${1:-}" in
+ --list)
+ echo "Cache-Verzeichnis: $MODELS_DIR"
+ FOUND=0
+ for CANDIDATE in "$MODELS_DIR"/*.gguf; do
+ if [ -f "$CANDIDATE" ]; then
+ FOUND=1
+ echo " $(basename "$CANDIDATE")"
+ fi
+ done
+ if [ "$FOUND" -eq 0 ]; then
+ echo " (leer)"
+ echo "Ein Modell holen: llm/fetch-model.sh unsloth/Qwen3.5-2B-GGUF"
+ fi
+ exit 0
+ ;;
+ --print)
+ PRINT_ONLY=1
+ shift
+ ;;
+ -h|--help|'')
+ usage
+ exit 2
+ ;;
+esac
+
+REPO=${1:-}
+if [ -z "$REPO" ]; then
+ usage
+ exit 2
+fi
+
+# Eine reine Formpruefung, keine Netzabfrage: user/repo, wahlweise mit :quant.
+# Ob es das Repository gibt, weiss nur huggingface.co, und dieses Skript
+# behauptet darueber nichts.
+if ! printf '%s' "$REPO" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+(:[A-Za-z0-9._-]+)?$'; then
+ echo "Das ist keine Repo-Kennung: $REPO" >&2
+ echo "Erwartet wird user/repo oder user/repo:quant, zum Beispiel" >&2
+ echo "unsloth/Qwen3.5-2B-GGUF oder unsloth/Qwen3.5-2B-GGUF:Q4_K_M." >&2
+ exit 2
+fi
+
+COMMAND="LLAMA_CACHE=\"$MODELS_DIR\" $SERVER -hf $REPO --no-warmup -c 512"
+
+echo "Dieser Aufruf geht ins Netz: er laedt $REPO von huggingface.co"
+echo "und legt die Datei in $MODELS_DIR ab."
+echo "Die Anwendung selbst laedt nichts herunter; sie zeigt nur diesen Aufruf."
+echo ""
+echo "$COMMAND"
+
+if [ "$PRINT_ONLY" -eq 1 ]; then
+ exit 0
+fi
+
+if [ ! -x "$SERVER" ]; then
+ echo "" >&2
+ echo "llama-server fehlt: $SERVER" >&2
+ echo "vendor/llama/ ist gitignoriert; Herkunft und Baubefehl stehen in vendor/llama/HERKUNFT.md." >&2
+ exit 4
+fi
+
+mkdir -p "$MODELS_DIR"
+
+# Im Vordergrund und ohne Kunstgriffe.
+#
+# `-hf` laedt und startet danach den Dienst; einen Modus "nur laden" gibt es
+# nicht. Dieses Skript raet deshalb nicht, wann der Download fertig ist, sondern
+# sagt, woran man es sieht, und ueberlaesst das Beenden dem Leser. Ein Skript,
+# das nach einer geschaetzten Frist zuschlaegt, waere ein Skript, das einen
+# halben Download fuer fertig erklaeren kann.
+echo ""
+echo "Der Server laedt jetzt und startet danach von selbst. Sobald in seiner"
+echo "Ausgabe \"server is listening\" steht, ist die Datei im Cache: dann mit"
+echo "Ctrl-C beenden. Die Datei bleibt liegen, und llm/start.sh findet sie."
+echo ""
+exec env LLAMA_CACHE="$MODELS_DIR" "$SERVER" -hf "$REPO" --no-warmup -c 512
diff --git a/graph-ui/llm/start.sh b/graph-ui/llm/start.sh
new file mode 100644
index 000000000..657ce70a3
--- /dev/null
+++ b/graph-ui/llm/start.sh
@@ -0,0 +1,280 @@
+#!/bin/sh
+# Den lokalen Modell-Sidecar starten. Ein Prozess, ein Port, eine PID-Datei.
+#
+# Warum es dieses Skript gibt und nicht einen Knopf in der Oberflaeche: die SPA
+# hat kein eigenes Backend und kann keinen Prozess starten (ADR 0001,
+# Abschnitt "Engine-Entscheidung"). Ein Knopf, der einen Prozess verspricht,
+# waere eine Luege in Knopfform. Der Manager im Browser erkennt, zeigt und
+# leitet an; gestartet wird hier, von Hand oder aus einem Beweislauf.
+#
+# ## Die Vorgabe ist seit W10 der Router, nicht ein Dateiname
+#
+# Ohne Argument faehrt dieses Skript llama-server ueber ein CACHE-VERZEICHNIS:
+#
+# --models-dir "$ATLAS_MODELS_DIR" --models-max N --models-autoload
+#
+# Damit listet /v1/models, was in dem Verzeichnis liegt, und das Feld "model"
+# einer Chat-Anfrage waehlt daraus aus. Das Einstellungen-Panel kann so zwischen
+# Modellen umschalten, ohne dass jemand einen Prozess neu startet, und ohne dass
+# die Oberflaeche einen Prozess anfassen muesste, was sie nicht darf.
+#
+# Der Grund fuer die Umstellung steht im Nachtrag W10 von docs/adr/0001: das
+# Programm liefert kein Modell mehr aus und nennt auch keins als gesetzt. Welche
+# Datei laeuft, entscheidet der Leser.
+#
+# ## Woher das Cache-Verzeichnis kommt
+#
+# ATLAS_MODELS_DIR, sonst $LLAMA_CACHE (die Umgebungsvariable, die llama.cpp
+# selbst fuer seinen Cache liest), sonst /models. Das letzte ist kein
+# Notbehelf: models/ IST das lokale Cache-Verzeichnis dieses Projekts. Es ist
+# gitignoriert, sein Inhalt gehoert dem Leser, und llm/fetch-model.sh laedt in
+# genau dieses Verzeichnis, damit ein geholtes Modell in derselben Liste
+# auftaucht wie die schon vorhandenen.
+#
+# ## Aufruf
+#
+# llm/start.sh Router ueber das Cache-Verzeichnis
+# llm/start.sh .gguf eine bestimmte Datei
+# llm/start.sh eine Datei aus dem Cache-Verzeichnis
+# llm/start.sh hf:user/repo[:quant] direkt von Hugging Face (geht ins Netz)
+# llm/start.sh class-a die Reproduktionsgriffe der ADR-Eval
+# llm/start.sh --print-command [..] zeigt die Befehlszeile und startet nichts
+#
+# Aus der Umgebung: ATLAS_MODEL (wie das Argument), ATLAS_CTX (Kontextfenster),
+# ATLAS_MODELS_DIR, ATLAS_MODELS_MAX (wie viele Instanzen gleichzeitig).
+#
+# ## --print-command, und warum es das gibt
+#
+# Ein Beweislauf soll pruefen koennen, WOMIT dieses Skript den Server aufruft,
+# ohne dafuer ein Modell zu laden. Ohne diesen Schalter muesste er Gigabyte in
+# den Speicher heben, um eine Zeichenkette zu sehen. Er schreibt die Zeile auf
+# stdout, startet nichts und fasst weder Port noch PID-Datei an.
+#
+# Idempotent: laeuft der eigene Sidecar schon, passiert nichts und das Skript
+# endet mit 0. Lauscht etwas ANDERES auf dem Port, wird nichts gestartet und
+# nichts abgeschossen; das Skript sagt, was es vorgefunden hat, und endet mit 3.
+
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "$HERE/.." && pwd)
+PORT=4141
+HOST=127.0.0.1
+PID_FILE="$HERE/.pid"
+LOG_FILE="$HERE/llama-server.log"
+SERVER="$ROOT/vendor/llama/llama-server"
+
+PRINT_ONLY=0
+if [ "${1:-}" = "--print-command" ]; then
+ PRINT_ONLY=1
+ shift
+fi
+
+# Das Cache-Verzeichnis. Siehe Kopf: die Reihenfolge ist Argument-Umgebung,
+# llama.cpp-Umgebung, Projektverzeichnis.
+MODELS_DIR=${ATLAS_MODELS_DIR:-${LLAMA_CACHE:-$ROOT/models}}
+# Wie viele Instanzen der Router gleichzeitig geladen halten darf. Zwei, weil
+# genau zwei gebraucht werden, um ohne Neustart umzuschalten: die laufende und
+# die gewaehlte. Jede weitere kostet den Speicher eines ganzen Modells fuer eine
+# Auswahl, die niemand getroffen hat. Die Vorgabe von llama-server waere 4.
+MODELS_MAX=${ATLAS_MODELS_MAX:-2}
+
+CHOICE=${1:-${ATLAS_MODEL:-}}
+
+# ---------------------------------------------------------------------------
+# Die Reproduktionsgriffe der ADR-Eval.
+#
+# Das ist KEINE Vorgabe mehr, und die Reihenfolge in dieser Datei sagt das: die
+# Vorgabe ist der Router weiter unten. Diese sechs Namen sind die Griffe, mit
+# denen die Laeufe von W5 wiederholbar bleiben (tools/smoke-w5a.mjs faehrt
+# class-a und erwartet Qwen3.5-2B mit Kontext 3072, tools/smoke-w5b.mjs und
+# tools/smoke-w6-full.mjs fahren die anderen, tools/eval-check.mjs erwartet die
+# Dateien unter models/). Sie sind Belege und keine Empfehlung; welches Modell
+# gut ist, sagt die Eval-Tabelle im ADR, und welches laeuft, sagt der Leser.
+#
+# Sie loesen darum ausdruecklich gegen /models auf und nicht gegen
+# $ATLAS_MODELS_DIR: die Dateien der Eval liegen dort, und ein gesetztes
+# LLAMA_CACHE wuerde sonst einen Reproduktionslauf ins Leere schicken.
+#
+# Die Wahlen hiessen bis zum 2026-08-29 "1b" und "4b". Befund 17 des
+# unabhaengigen Audits: der Sieger der Klasse A ist ein 2B-Modell, und eine
+# Beschriftung, die es "1b" nennt, sagt etwas Falsches ueber die Datei, die sie
+# laedt. Die Klasse haengt ohnehin am Kontextfenster und nicht an der
+# Parameterzahl (siehe modelClassOf in src/llm/sidecar.ts). Die alten Namen
+# bleiben als Alias gueltig.
+# ---------------------------------------------------------------------------
+ALIAS_FILE=''
+ALIAS_CTX=''
+case "$CHOICE" in
+ class-a|1b) ALIAS_FILE=Qwen3.5-2B-Q4_K_M.gguf ; ALIAS_CTX=3072 ;;
+ class-a-lfm|1b-lfm) ALIAS_FILE=LFM2.5-1.2B-Instruct-Q4_K_M.gguf ; ALIAS_CTX=3072 ;;
+ class-a-minicpm|1b-minicpm) ALIAS_FILE=MiniCPM5-1B-Q4_K_M.gguf ; ALIAS_CTX=3072 ;;
+ class-a-coder|1b-coder) ALIAS_FILE=qwen2.5-coder-1.5b-instruct-q4_k_m.gguf ; ALIAS_CTX=3072 ;;
+ class-b|4b) ALIAS_FILE=Qwen3.5-4B-Q4_K_M.gguf ; ALIAS_CTX=8192 ;;
+ class-b-gemma|4b-gemma) ALIAS_FILE=gemma-4-E4B-it-Q4_K_M.gguf ; ALIAS_CTX=8192 ;;
+esac
+
+case "$CHOICE" in
+ 1b|1b-lfm|1b-minicpm|1b-coder|4b|4b-gemma)
+ echo "Hinweis: \"$CHOICE\" ist der alte Name dieser Wahl. Sie heisst jetzt nach ihrer" >&2
+ echo "Klasse; die Klassen sind class-a (Kontext 3072) und class-b (Kontext 8192)." >&2
+ ;;
+esac
+
+# Der Kontext: die Umgebung schlaegt den Alias, der Alias die Vorgabe. 3072 ist
+# das Budget der Klasse A aus PLAN Paragraph 5 und damit die konservative Wahl
+# fuer ein Modell, ueber das dieses Skript nichts weiss.
+CTX=${ATLAS_CTX:-${ALIAS_CTX:-3072}}
+
+# ---------------------------------------------------------------------------
+# Die Argumente des Servers, in genau einer Variablen.
+#
+# Sie entstehen hier und werden weiter unten entweder gedruckt oder gefahren.
+# Zwei Stellen, an denen die Zeile entsteht, waeren zwei Zeilen: der Beweislauf
+# saehe die eine und der Leser bekaeme die andere.
+# ---------------------------------------------------------------------------
+MODE=''
+DESCRIPTION=''
+set -- --host "$HOST" --port "$PORT"
+
+if [ -n "$ALIAS_FILE" ]; then
+ MODE=eval-alias
+ MODEL_PATH="$ROOT/models/$ALIAS_FILE"
+ if [ "$PRINT_ONLY" -eq 0 ] && [ ! -f "$MODEL_PATH" ]; then
+ echo "Modell fehlt: $MODEL_PATH" >&2
+ echo "\"$CHOICE\" ist ein Reproduktionsgriff der ADR-Eval und erwartet die Datei aus" >&2
+ echo "models/SHA256SUMS. Holen: llm/fetch-model.sh --list" >&2
+ exit 4
+ fi
+ # Der relative Pfad, byte-identisch zu dem, was dieses Skript vor W10
+ # uebergeben hat: /props meldet ihn woertlich zurueck, und die Beweislaeufe
+ # von W5 und W6 lesen ihn dort.
+ set -- "$@" -m "models/$ALIAS_FILE" -c "$CTX"
+ DESCRIPTION="$ALIAS_FILE (Reproduktionsgriff $CHOICE), Kontext $CTX"
+elif [ -z "$CHOICE" ]; then
+ MODE=router
+ set -- "$@" --models-dir "$MODELS_DIR" --models-max "$MODELS_MAX" --models-autoload -c "$CTX"
+ DESCRIPTION="Router ueber $MODELS_DIR (hoechstens $MODELS_MAX geladen), Kontext $CTX"
+else
+ case "$CHOICE" in
+ hf:*)
+ MODE=hf
+ REPO=${CHOICE#hf:}
+ set -- "$@" -hf "$REPO" -c "$CTX"
+ DESCRIPTION="$REPO von huggingface.co, Kontext $CTX"
+ ;;
+ *)
+ MODE=file
+ # Ein Pfad, oder ein Name im Cache-Verzeichnis. Erst wird gefragt,
+ # ob die Datei so existiert; erst dann wird sie im Cache gesucht.
+ if [ -f "$CHOICE" ]; then
+ MODEL_PATH="$CHOICE"
+ elif [ -f "$MODELS_DIR/$CHOICE" ]; then
+ MODEL_PATH="$MODELS_DIR/$CHOICE"
+ elif [ -f "$MODELS_DIR/$CHOICE.gguf" ]; then
+ MODEL_PATH="$MODELS_DIR/$CHOICE.gguf"
+ else
+ echo "unbekannte Modellwahl: $CHOICE" >&2
+ echo "Erwartet wird eines von:" >&2
+ echo " ein Pfad auf eine .gguf-Datei" >&2
+ echo " ein Dateiname aus $MODELS_DIR" >&2
+ echo " hf:user/repo[:quant] (laedt von huggingface.co)" >&2
+ echo " ein Reproduktionsgriff der ADR-Eval, davon" >&2
+ # Diese Zeile ist eine Naht: src/llm/strings.test.ts liest sie aus
+ # diesem Skript und vergleicht sie mit LLM_MODEL_CHOICES. Die
+ # Liste steht damit einmal in der Wahrheit und einmal in der
+ # Anzeige, und ein Test haelt beide zusammen (Audit-Befund 17).
+ echo "erlaubt: class-a | class-a-lfm | class-a-minicpm | class-a-coder | class-b | class-b-gemma" >&2
+ echo " (die alten Namen 1b, 1b-lfm, 1b-minicpm, 1b-coder, 4b, 4b-gemma gelten weiter)" >&2
+ echo "Ohne Argument faehrt dieses Skript den Router ueber $MODELS_DIR." >&2
+ exit 2
+ fi
+ set -- "$@" -m "$MODEL_PATH" -c "$CTX"
+ DESCRIPTION="$MODEL_PATH, Kontext $CTX"
+ ;;
+ esac
+fi
+
+if [ "$PRINT_ONLY" -eq 1 ]; then
+ printf '%s' "$SERVER"
+ for ARG in "$@"; do
+ printf ' %s' "$ARG"
+ done
+ printf '\n'
+ exit 0
+fi
+
+if [ ! -x "$SERVER" ]; then
+ echo "llama-server fehlt: $SERVER" >&2
+ echo "vendor/llama/ ist gitignoriert; Herkunft und Baubefehl stehen in vendor/llama/HERKUNFT.md." >&2
+ exit 4
+fi
+
+# Ein leeres Cache-Verzeichnis ist kein Start, sondern eine Auskunft.
+#
+# Ohne diese Pruefung liefe der Router an und antwortete auf jede Anfrage mit
+# einer leeren Modell-Liste, und aus der Ferne saehe das aus wie ein Defekt.
+# Gesagt wird darum, was fehlt und wie man es holt.
+if [ "$MODE" = router ]; then
+ # Ein Glob und kein find: `find -quit` gibt es nicht ueberall, und die Frage
+ # ist ohnehin nur, ob EINE Datei da ist.
+ HAVE_GGUF=0
+ for CANDIDATE in "$MODELS_DIR"/*.gguf; do
+ if [ -f "$CANDIDATE" ]; then
+ HAVE_GGUF=1
+ break
+ fi
+ done
+ if [ "$HAVE_GGUF" -eq 0 ]; then
+ echo "Kein Modell im Cache-Verzeichnis: $MODELS_DIR" >&2
+ echo "Dieses Programm liefert keines mit. So holen Sie eines (geht ins Netz):" >&2
+ echo " llm/fetch-model.sh unsloth/Qwen3.5-2B-GGUF" >&2
+ echo "oder direkt:" >&2
+ echo " LLAMA_CACHE=\"$MODELS_DIR\" $SERVER -hf unsloth/Qwen3.5-2B-GGUF:Q4_K_M" >&2
+ echo "Ein anderes Verzeichnis: ATLAS_MODELS_DIR=/pfad/zum/cache llm/start.sh" >&2
+ echo "Die Vorschlaege mit ihren gemessenen Zahlen stehen im Einstellungen-Panel." >&2
+ exit 5
+ fi
+fi
+
+# Laeuft der eigene Prozess schon? Die PID-Datei allein reicht als Auskunft
+# nicht: sie ueberlebt einen Absturz. Gefragt wird deshalb das Betriebssystem.
+if [ -f "$PID_FILE" ]; then
+ OLD=$(cat "$PID_FILE" 2>/dev/null || echo '')
+ if [ -n "$OLD" ] && kill -0 "$OLD" 2>/dev/null; then
+ echo "Sidecar laeuft bereits (pid $OLD) auf $HOST:$PORT. Nichts zu tun."
+ exit 0
+ fi
+ echo "verwaiste PID-Datei ($OLD) wird entfernt."
+ rm -f "$PID_FILE"
+fi
+
+# Der Port gehoert dem Sidecar. Wer sonst dort lauscht, wird nicht abgeschossen:
+# ein Startskript, das fremde Prozesse beendet, ist ein Startskript, dem man
+# nicht mehr zusehen kann.
+if command -v lsof >/dev/null 2>&1; then
+ BUSY=$(lsof -ti "tcp:$PORT" 2>/dev/null || true)
+ if [ -n "$BUSY" ]; then
+ echo "Port $PORT ist belegt (pid $BUSY), aber nicht von diesem Skript." >&2
+ echo "Nichts gestartet und nichts beendet." >&2
+ exit 3
+ fi
+fi
+
+if [ "$MODE" = hf ]; then
+ echo "Dieser Aufruf geht ins Netz (huggingface.co) und laedt nach ${LLAMA_CACHE:-$MODELS_DIR}."
+fi
+
+cd "$ROOT"
+nohup "$SERVER" "$@" > "$LOG_FILE" 2>&1 &
+PID=$!
+echo "$PID" > "$PID_FILE"
+
+echo "Sidecar gestartet: pid $PID, $DESCRIPTION, http://$HOST:$PORT"
+if [ "$MODE" = router ]; then
+ echo "Die Modelle im Cache: curl -s http://$HOST:$PORT/v1/models"
+ echo "Umschalten ohne Neustart: im Einstellungen-Panel der Oberflaeche."
+fi
+echo "Bereitschaft: curl -s http://$HOST:$PORT/health -> {\"status\":\"ok\"}"
+echo "Log: $LOG_FILE"
+echo "Beenden: $HERE/stop.sh"
diff --git a/graph-ui/llm/stop.sh b/graph-ui/llm/stop.sh
new file mode 100644
index 000000000..6cf8e280a
--- /dev/null
+++ b/graph-ui/llm/stop.sh
@@ -0,0 +1,54 @@
+#!/bin/sh
+# Den eigenen Sidecar beenden, und nur den eigenen.
+#
+# Beendet wird ausschliesslich die PID aus llm/.pid. Kein pkill auf einen
+# Namen, kein "was auch immer auf 4141 lauscht": beides wuerde fremde Prozesse
+# treffen, die dieses Skript nie gestartet hat. Steht in der Datei eine PID, die
+# es nicht mehr gibt, wird die Datei aufgeraeumt und das gesagt.
+#
+# Aufruf: llm/stop.sh
+
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+PID_FILE="$HERE/.pid"
+
+if [ ! -f "$PID_FILE" ]; then
+ echo "keine PID-Datei ($PID_FILE): dieses Skript hat gerade nichts laufen."
+ exit 0
+fi
+
+PID=$(cat "$PID_FILE" 2>/dev/null || echo '')
+if [ -z "$PID" ]; then
+ echo "PID-Datei war leer, wird entfernt."
+ rm -f "$PID_FILE"
+ exit 0
+fi
+
+if ! kill -0 "$PID" 2>/dev/null; then
+ echo "pid $PID laeuft nicht mehr; die PID-Datei wird entfernt."
+ rm -f "$PID_FILE"
+ exit 0
+fi
+
+kill "$PID" 2>/dev/null || true
+
+# Erst hoeflich, dann nicht mehr. Fuenf Sekunden reichen dem Sidecar, um seine
+# Slots abzuraeumen; laenger zu warten hiesse, jedem Beweislauf eine Pause
+# aufzuzwingen, die nur der Hoeflichkeit dient.
+i=0
+while [ "$i" -lt 50 ]; do
+ if ! kill -0 "$PID" 2>/dev/null; then
+ rm -f "$PID_FILE"
+ echo "Sidecar beendet (pid $PID)."
+ exit 0
+ fi
+ sleep 0.1
+ i=$((i + 1))
+done
+
+echo "pid $PID reagierte nicht auf SIGTERM, sende SIGKILL." >&2
+kill -9 "$PID" 2>/dev/null || true
+sleep 0.3
+rm -f "$PID_FILE"
+echo "Sidecar hart beendet (pid $PID)."
diff --git a/graph-ui/models/SHA256SUMS b/graph-ui/models/SHA256SUMS
new file mode 100644
index 000000000..cdb546490
--- /dev/null
+++ b/graph-ui/models/SHA256SUMS
@@ -0,0 +1,7 @@
+# Diese sechs Summen sind der BELEG der Eval-Laeufe aus W5 (docs/adr/0001-modellwahl.md, Abschnitt "Eval-Ergebnis"), nicht eine Liste von Dateien, die dieses Programm mitliefert oder erwartet: seit W10 waehlt der Leser sein Modell selbst, models/ ist sein Cache, und wer nur drei dieser Dateien liegen hat, hat nichts Kaputtes, sondern drei Modelle.
+85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87 gemma-4-E4B-it-Q4_K_M.gguf
+b1b3de114215d9507409a662a501a631095a479a419584e8a2ded6304b19b4f5 LFM2.5-1.2B-Instruct-Q4_K_M.gguf
+81b64d05a23b17b34c475f42b3e72fbde62d4b92cc34541f7a8031d0752deafa MiniCPM5-1B-Q4_K_M.gguf
+cc324af070c2ecbfd324a30884d2f951a7ff756aba85cb811a6ec436933bb046 qwen2.5-coder-1.5b-instruct-q4_k_m.gguf
+aaf42c8b7c3cab2bf3d69c355048d4a0ee9973d48f16c731c0520ee914699223 Qwen3.5-2B-Q4_K_M.gguf
+3874209241c9a397e2f62cd3f70f80fd2dfbf0dfccb6838416bdb48a714e8630 Qwen3.5-4B-Q4_K_M.gguf
diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json
index b5441de62..c444f4e41 100644
--- a/graph-ui/package-lock.json
+++ b/graph-ui/package-lock.json
@@ -1,48 +1,41 @@
{
- "name": "graph-ui",
- "version": "0.1.0",
+ "name": "cbm-lens",
+ "version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "graph-ui",
- "version": "0.1.0",
+ "name": "cbm-lens",
+ "version": "0.0.1",
"dependencies": {
- "@react-three/drei": "^10.7.0",
- "@react-three/fiber": "^9.5.0",
- "@react-three/postprocessing": "^3.0.4",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "lucide-react": "^0.577.0",
- "postprocessing": "^6.38.3",
- "radix-ui": "^1.4.3",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "tailwind-merge": "^3.5.0",
- "three": "~0.183.0"
+ "@huggingface/transformers": "4.2.0",
+ "@react-three/drei": "10.7.7",
+ "@react-three/fiber": "9.5.0",
+ "@react-three/postprocessing": "3.0.4",
+ "postprocessing": "6.38.3",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "react-markdown": "10.1.0",
+ "remark-gfm": "4.0.1",
+ "sharp": "file:./stubs/sharp",
+ "three": "0.183.2",
+ "yaml": "2.8.3"
},
"devDependencies": {
- "@tailwindcss/vite": "^4.2.1",
- "@testing-library/jest-dom": "^6.6.0",
- "@testing-library/react": "^16.1.0",
- "@types/react": "^19.0.0",
- "@types/react-dom": "^19.0.0",
- "@types/three": "~0.183.0",
- "@vitejs/plugin-react": "^4.3.0",
- "jsdom": "^25.0.0",
- "tailwindcss": "^4.1.0",
- "typescript": "^5.7.0",
- "vite": "^6.4.3",
- "vitest": "^4.1.11"
+ "@types/node": "20.19.43",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.5",
+ "@types/three": "0.183.1",
+ "@vitejs/plugin-react": "4.7.0",
+ "jsdom": "25.0.1",
+ "monaco-editor": "0.56.0",
+ "playwright": "1.62.1",
+ "three-stdlib": "2.36.1",
+ "typescript": "5.9.3",
+ "vite": "6.4.3",
+ "vitest": "4.1.11"
}
},
- "node_modules/@adobe/css-tools": {
- "version": "4.4.4",
- "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
- "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
@@ -121,14 +114,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
- "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -197,9 +190,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
- "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -251,13 +244,13 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
- "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.7"
+ "@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -267,13 +260,13 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
- "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -283,13 +276,13 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
- "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -299,9 +292,9 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
- "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -323,18 +316,18 @@
}
},
"node_modules/@babel/traverse": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
- "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
+ "@babel/generator": "^7.29.8",
"@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.7",
+ "@babel/parser": "^7.29.8",
"@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7",
+ "@babel/types": "^7.29.8",
"debug": "^4.3.1"
},
"engines": {
@@ -342,9 +335,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
- "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -918,44 +911,34 @@
"node": ">=18"
}
},
- "node_modules/@floating-ui/core": {
- "version": "1.7.5",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
- "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "node_modules/@huggingface/jinja": {
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz",
+ "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==",
"license": "MIT",
- "dependencies": {
- "@floating-ui/utils": "^0.2.11"
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@floating-ui/dom": {
- "version": "1.7.6",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
- "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.7.5",
- "@floating-ui/utils": "^0.2.11"
- }
+ "node_modules/@huggingface/tokenizers": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
+ "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
+ "license": "Apache-2.0"
},
- "node_modules/@floating-ui/react-dom": {
- "version": "2.1.8",
- "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
- "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
- "license": "MIT",
+ "node_modules/@huggingface/transformers": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
+ "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
+ "license": "Apache-2.0",
"dependencies": {
- "@floating-ui/dom": "^1.7.6"
- },
- "peerDependencies": {
- "react": ">=16.8.0",
- "react-dom": ">=16.8.0"
+ "@huggingface/jinja": "^0.5.6",
+ "@huggingface/tokenizers": "^0.1.3",
+ "onnxruntime-node": "1.24.3",
+ "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
+ "sharp": "^0.34.5"
}
},
- "node_modules/@floating-ui/utils": {
- "version": "0.2.11",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
- "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
- "license": "MIT"
- },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -989,9 +972,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -1024,1503 +1007,79 @@
"three": ">= 0.159.0"
}
},
- "node_modules/@radix-ui/number": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
- "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
- "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
- "license": "MIT"
- },
- "node_modules/@radix-ui/react-accessible-icon": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz",
- "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==",
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
"license": "MIT",
- "dependencies": {
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
}
},
- "node_modules/@radix-ui/react-accordion": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
- "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collapsible": "1.1.12",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-alert-dialog": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
- "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dialog": "1.1.15",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-arrow": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
- "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-aspect-ratio": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
- "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-avatar": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
- "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-is-hydrated": "0.1.0",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-checkbox": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
- "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-collapsible": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
- "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-collection": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
- "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-compose-refs": {
+ "node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
- "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
},
- "node_modules/@radix-ui/react-context": {
+ "node_modules/@protobufjs/base64": {
"version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
- "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
},
- "node_modules/@radix-ui/react-context-menu": {
- "version": "2.2.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
- "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
},
- "node_modules/@radix-ui/react-dialog": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
- "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-direction": {
+ "node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
- "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
},
- "node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
- "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-escape-keydown": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-dropdown-menu": {
- "version": "2.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
- "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-focus-guards": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
- "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-focus-scope": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
- "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-form": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz",
- "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-label": "2.1.7",
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-hover-card": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
- "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-id": {
+ "node_modules/@protobufjs/fetch": {
"version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
- "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
- "license": "MIT",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
"dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
+ "@protobufjs/aspromise": "^1.1.1"
}
},
- "node_modules/@radix-ui/react-label": {
- "version": "2.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
- "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
},
- "node_modules/@radix-ui/react-menu": {
- "version": "2.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
- "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
},
- "node_modules/@radix-ui/react-menubar": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz",
- "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
},
- "node_modules/@radix-ui/react-navigation-menu": {
- "version": "1.2.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
- "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-one-time-password-field": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz",
- "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-is-hydrated": "0.1.0",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-password-toggle-field": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz",
- "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-is-hydrated": "0.1.0"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-popover": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
- "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-popper": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
- "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/react-dom": "^2.0.0",
- "@radix-ui/react-arrow": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-rect": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1",
- "@radix-ui/rect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-portal": {
- "version": "1.1.9",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
- "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-presence": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
- "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-primitive": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
- "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-slot": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-progress": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
- "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-radio-group": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
- "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-roving-focus": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
- "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-scroll-area": {
- "version": "1.2.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
- "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-select": {
- "version": "2.2.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
- "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3",
- "aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-separator": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
- "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-slider": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
- "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/number": "1.1.1",
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-slot": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
- "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-switch": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
- "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-previous": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-tabs": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
- "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toast": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
- "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toggle": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
- "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toggle-group": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
- "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-toggle": "1.1.10",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-toolbar": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz",
- "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-separator": "1.1.7",
- "@radix-ui/react-toggle-group": "1.1.11"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-tooltip": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
- "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-callback-ref": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
- "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-controllable-state": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
- "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-effect-event": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
- "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-escape-keydown": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
- "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-callback-ref": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-is-hydrated": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
- "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
- "license": "MIT",
- "dependencies": {
- "use-sync-external-store": "^1.5.0"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-layout-effect": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
- "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-previous": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
- "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-rect": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
- "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/rect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-use-size": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
- "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-visually-hidden": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
- "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-primitive": "2.1.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/rect": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
- "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
- "license": "MIT"
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
+ "license": "BSD-3-Clause"
},
"node_modules/@react-three/drei": {
"version": "10.7.7",
@@ -2644,9 +1203,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
- "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
"cpu": [
"arm"
],
@@ -2658,9 +1217,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
- "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
"cpu": [
"arm64"
],
@@ -2672,9 +1231,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
- "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
"cpu": [
"arm64"
],
@@ -2686,9 +1245,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
- "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
"cpu": [
"x64"
],
@@ -2700,9 +1259,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
- "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
"cpu": [
"arm64"
],
@@ -2714,9 +1273,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
- "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
"cpu": [
"x64"
],
@@ -2728,9 +1287,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
- "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
"cpu": [
"arm"
],
@@ -2742,9 +1301,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
- "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
"cpu": [
"arm"
],
@@ -2756,9 +1315,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
- "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
"cpu": [
"arm64"
],
@@ -2770,9 +1329,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
- "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
"cpu": [
"arm64"
],
@@ -2784,9 +1343,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
- "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
"cpu": [
"loong64"
],
@@ -2798,9 +1357,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
- "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
"cpu": [
"loong64"
],
@@ -2812,9 +1371,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
- "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
"cpu": [
"ppc64"
],
@@ -2826,9 +1385,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
- "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
"cpu": [
"ppc64"
],
@@ -2840,9 +1399,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
- "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
"cpu": [
"riscv64"
],
@@ -2854,9 +1413,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
- "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
"cpu": [
"riscv64"
],
@@ -2868,9 +1427,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
- "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
"cpu": [
"s390x"
],
@@ -2882,9 +1441,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
- "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
"cpu": [
"x64"
],
@@ -2896,9 +1455,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
- "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
"cpu": [
"x64"
],
@@ -2910,9 +1469,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
- "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
"cpu": [
"x64"
],
@@ -2924,9 +1483,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
- "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
"cpu": [
"arm64"
],
@@ -2938,9 +1497,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
- "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
"cpu": [
"arm64"
],
@@ -2952,9 +1511,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
- "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
"cpu": [
"ia32"
],
@@ -2966,9 +1525,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
- "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
"cpu": [
"x64"
],
@@ -2980,9 +1539,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
- "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
"cpu": [
"x64"
],
@@ -3000,432 +1559,12 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@tailwindcss/node": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
- "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "enhanced-resolve": "^5.19.0",
- "jiti": "^2.6.1",
- "lightningcss": "1.31.1",
- "magic-string": "^0.30.21",
- "source-map-js": "^1.2.1",
- "tailwindcss": "4.2.1"
- }
- },
- "node_modules/@tailwindcss/oxide": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
- "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 20"
- },
- "optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.2.1",
- "@tailwindcss/oxide-darwin-arm64": "4.2.1",
- "@tailwindcss/oxide-darwin-x64": "4.2.1",
- "@tailwindcss/oxide-freebsd-x64": "4.2.1",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
- "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
- "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
- "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
- "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
- "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
- }
- },
- "node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
- "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
- "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
- "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
- "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
- "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
- "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
- "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
- "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
- "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
- "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
- "bundleDependencies": [
- "@napi-rs/wasm-runtime",
- "@emnapi/core",
- "@emnapi/runtime",
- "@tybys/wasm-util",
- "@emnapi/wasi-threads",
- "tslib"
- ],
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "^1.8.1",
- "@emnapi/runtime": "^1.8.1",
- "@emnapi/wasi-threads": "^1.1.0",
- "@napi-rs/wasm-runtime": "^1.1.1",
- "@tybys/wasm-util": "^0.10.1",
- "tslib": "^2.8.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
- "version": "1.8.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.1.0",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
- "version": "1.1.0",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1",
- "@tybys/wasm-util": "^0.10.1"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
- "version": "0.10.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
- "version": "2.8.1",
- "dev": true,
- "inBundle": true,
- "license": "0BSD",
- "optional": true
- },
- "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
- "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
- "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/@tailwindcss/vite": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz",
- "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@tailwindcss/node": "4.2.1",
- "@tailwindcss/oxide": "4.2.1",
- "tailwindcss": "4.2.1"
- },
- "peerDependencies": {
- "vite": "^5.2.0 || ^6 || ^7"
- }
- },
- "node_modules/@testing-library/dom": {
- "version": "10.4.1",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
- "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/code-frame": "^7.10.4",
- "@babel/runtime": "^7.12.5",
- "@types/aria-query": "^5.0.1",
- "aria-query": "5.3.0",
- "dom-accessibility-api": "^0.5.9",
- "lz-string": "^1.5.0",
- "picocolors": "1.1.1",
- "pretty-format": "^27.0.2"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@testing-library/jest-dom": {
- "version": "6.9.1",
- "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
- "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@adobe/css-tools": "^4.4.0",
- "aria-query": "^5.0.0",
- "css.escape": "^1.5.1",
- "dom-accessibility-api": "^0.6.3",
- "picocolors": "^1.1.1",
- "redent": "^3.0.0"
- },
- "engines": {
- "node": ">=14",
- "npm": ">=6",
- "yarn": ">=1"
- }
- },
- "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
- "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@testing-library/react": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
- "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.12.5"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "@testing-library/dom": "^10.0.0",
- "@types/react": "^18.0.0 || ^19.0.0",
- "@types/react-dom": "^18.0.0 || ^19.0.0",
- "react": "^18.0.0 || ^19.0.0",
- "react-dom": "^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
- "node_modules/@types/aria-query": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
- "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3482,6 +1621,15 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3496,12 +1644,53 @@
"license": "MIT"
},
"node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
@@ -3509,19 +1698,19 @@
"license": "MIT"
},
"node_modules/@types/react": {
- "version": "19.2.14",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
- "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
- "version": "19.2.3",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
- "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "devOptional": true,
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -3557,12 +1746,32 @@
"meshoptimizer": "~1.0.1"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
+ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
+ "license": "ISC"
+ },
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
@@ -3716,11 +1925,20 @@
}
},
"node_modules/@webgpu/types": {
- "version": "0.1.69",
- "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
- "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+ "version": "0.1.72",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
+ "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
"license": "BSD-3-Clause"
},
+ "node_modules/adm-zip": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",
+ "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -3731,53 +1949,6 @@
"node": ">= 14"
}
},
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
- "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/aria-hidden": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
- "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/aria-query": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
- "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "dequal": "^2.0.3"
- }
- },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -3795,6 +1966,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3816,9 +1997,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.11.21",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
- "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
+ "version": "2.11.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3838,9 +2019,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.9",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
- "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -3858,11 +2039,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.11.20",
- "caniuse-lite": "^1.0.30001810",
- "electron-to-chromium": "^1.5.420",
- "node-releases": "^2.0.54",
- "update-browserslist-db": "^1.3.2"
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -3910,13 +2091,13 @@
}
},
"node_modules/camera-controls": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
- "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz",
+ "integrity": "sha512-w5oULNpijgTRH0ARFJJ0R5ct1nUM3R3WP7/b8A6j9uTGpRfnsypc/RBMPQV8JQDPayUe37p/TZZY1PcUr4czOQ==",
"license": "MIT",
"engines": {
- "node": ">=22.0.0",
- "npm": ">=10.5.1"
+ "node": ">=20.11.0",
+ "npm": ">=10.8.2"
},
"peerDependencies": {
"three": ">=0.126.1"
@@ -3943,6 +2124,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -3953,25 +2144,44 @@
"node": ">=18"
}
},
- "node_modules/class-variance-authority": {
- "version": "0.7.1",
- "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
- "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
- "license": "Apache-2.0",
- "dependencies": {
- "clsx": "^2.1.1"
- },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
"funding": {
- "url": "https://polar.sh/cva"
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/clsx": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
- "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
- "engines": {
- "node": ">=6"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/combined-stream": {
@@ -3987,6 +2197,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4026,13 +2246,6 @@
"node": ">= 8"
}
},
- "node_modules/css.escape": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
- "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -4078,7 +2291,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4099,6 +2311,53 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -4113,7 +2372,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4128,29 +2386,28 @@
"webgl-constants": "^1.1.1"
}
},
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/detect-node-es": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
- "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
- "license": "MIT"
- },
- "node_modules/dom-accessibility-api": {
- "version": "0.5.16",
- "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
- "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "node_modules/dompurify": {
+ "version": "3.4.16",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
+ "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
},
"node_modules/draco3d": {
"version": "1.5.7",
@@ -4174,26 +2431,12 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.422",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
- "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==",
+ "version": "1.5.416",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz",
+ "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==",
"dev": true,
"license": "ISC"
},
- "node_modules/enhanced-resolve": {
- "version": "5.20.1",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
- "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.4",
- "tapable": "^2.3.0"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -4211,7 +2454,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4221,23 +2463,22 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
- "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4315,6 +2556,28 @@
"node": ">=6"
}
},
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -4326,15 +2589,21 @@
}
},
"node_modules/expect-type": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
- "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4359,6 +2628,12 @@
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
+ "node_modules/flatbuffers": {
+ "version": "25.9.23",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
+ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@@ -4377,9 +2652,9 @@
}
},
"node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -4436,15 +2711,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/get-nonce": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
- "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -4459,6 +2725,49 @@
"node": ">= 0.4"
}
},
+ "node_modules/global-agent": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
+ "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "globalthis": "^1.0.2",
+ "matcher": "^4.0.0",
+ "semver": "^7.3.5",
+ "serialize-error": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/global-agent/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/glsl-noise": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
@@ -4469,7 +2778,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4478,13 +2786,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "dev": true,
+ "node_modules/guid-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC"
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -4527,10 +2846,50 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hls.js": {
- "version": "1.6.15",
- "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
- "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.1.tgz",
+ "integrity": "sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==",
"license": "Apache-2.0"
},
"node_modules/html-encoding-sniffer": {
@@ -4546,6 +2905,16 @@
"node": ">=18"
}
},
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4613,14 +2982,66 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
- "node_modules/indent-string": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
- "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
- "dev": true,
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"license": "MIT",
"engines": {
- "node": ">=8"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
@@ -4654,16 +3075,6 @@
"react": "^19.0.0"
}
},
- "node_modules/jiti": {
- "version": "2.6.1",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
- "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jiti": "lib/jiti-cli.mjs"
- }
- },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4747,265 +3158,20 @@
"immediate": "~3.0.5"
}
},
- "node_modules/lightningcss": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
- "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
- "dev": true,
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.31.1",
- "lightningcss-darwin-arm64": "1.31.1",
- "lightningcss-darwin-x64": "1.31.1",
- "lightningcss-freebsd-x64": "1.31.1",
- "lightningcss-linux-arm-gnueabihf": "1.31.1",
- "lightningcss-linux-arm64-gnu": "1.31.1",
- "lightningcss-linux-arm64-musl": "1.31.1",
- "lightningcss-linux-x64-gnu": "1.31.1",
- "lightningcss-linux-x64-musl": "1.31.1",
- "lightningcss-win32-arm64-msvc": "1.31.1",
- "lightningcss-win32-x64-msvc": "1.31.1"
- }
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
},
- "node_modules/lightningcss-android-arm64": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
- "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
- "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
- "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
- "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
- "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
- "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
- "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
- "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
- "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
- "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.31.1",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
- "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
@@ -5018,26 +3184,6 @@
"yallist": "^3.0.2"
}
},
- "node_modules/lucide-react": {
- "version": "0.577.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
- "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
- "license": "ISC",
- "peerDependencies": {
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
- "node_modules/lz-string": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
- "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "bin": {
- "lz-string": "bin/bin.js"
- }
- },
"node_modules/maath": {
"version": "0.10.8",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
@@ -5058,6 +3204,44 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/marked": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+ "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
+ "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5068,6 +3252,288 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/meshline": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
@@ -5083,6 +3549,569 @@
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
},
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -5106,27 +4135,27 @@
"node": ">= 0.6"
}
},
- "node_modules/min-indent": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
- "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "node_modules/monaco-editor": {
+ "version": "0.56.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz",
+ "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">=4"
+ "dependencies": {
+ "dompurify": "3.4.8",
+ "marked": "14.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/n8ao": {
- "version": "1.10.1",
- "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz",
- "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==",
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.3.tgz",
+ "integrity": "sha512-JFRk4WgUAUWO2KdJTqmBT02dP9kG7rE2oQbBy64E9+YwQ3d96KB9QBanqNy/NnYUfUTYU6Z/5OS5t/yLey7+6Q==",
"license": "ISC",
"peerDependencies": {
"postprocessing": ">=6.30.0",
@@ -5163,21 +4192,101 @@
}
},
"node_modules/nwsapi": {
- "version": "2.2.23",
- "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
- "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "version": "2.2.25",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.25.tgz",
+ "integrity": "sha512-kktSgNDIbyEIs/LrE6dtEMnfzxSDpXViFl6c4gFjz/CgtnL4GDCR3wyZXzjvAXsNB8dXU4dAfvIOgLMH2/vwLg==",
"dev": true,
"license": "MIT"
},
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/obug": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
- "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/onnxruntime-common": {
+ "version": "1.24.3",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
+ "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
+ "license": "MIT"
+ },
+ "node_modules/onnxruntime-node": {
+ "version": "1.24.3",
+ "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
+ "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "os": [
+ "win32",
+ "darwin",
+ "linux"
+ ],
+ "dependencies": {
+ "adm-zip": "^0.5.16",
+ "global-agent": "^3.0.0",
+ "onnxruntime-common": "1.24.3"
+ }
+ },
+ "node_modules/onnxruntime-web": {
+ "version": "1.26.0-dev.20260416-b7804b056c",
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
+ "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
+ "license": "MIT",
+ "dependencies": {
+ "flatbuffers": "^25.1.24",
+ "guid-typescript": "^1.0.9",
+ "long": "^5.2.3",
+ "onnxruntime-common": "1.24.0-dev.20251116-b39e144322",
+ "platform": "^1.3.6",
+ "protobufjs": "^7.2.4"
+ }
+ },
+ "node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
+ "version": "1.24.0-dev.20251116-b39e144322",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
+ "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
+ "license": "MIT"
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
@@ -5217,9 +4326,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5229,10 +4338,48 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/platform": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
+ "license": "MIT"
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/postcss": {
- "version": "8.5.24",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
- "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==",
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@@ -5250,7 +4397,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.16",
+ "nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -5273,22 +4420,6 @@
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC"
},
- "node_modules/pretty-format": {
- "version": "27.5.1",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
- "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "ansi-regex": "^5.0.1",
- "ansi-styles": "^5.0.0",
- "react-is": "^17.0.1"
- },
- "engines": {
- "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
- }
- },
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
@@ -5299,6 +4430,39 @@
"lie": "^3.0.2"
}
},
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/protobufjs": {
+ "version": "7.6.6",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
+ "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5309,111 +4473,53 @@
"node": ">=6"
}
},
- "node_modules/radix-ui": {
- "version": "1.4.3",
- "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
- "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-accessible-icon": "1.1.7",
- "@radix-ui/react-accordion": "1.2.12",
- "@radix-ui/react-alert-dialog": "1.1.15",
- "@radix-ui/react-arrow": "1.1.7",
- "@radix-ui/react-aspect-ratio": "1.1.7",
- "@radix-ui/react-avatar": "1.1.10",
- "@radix-ui/react-checkbox": "1.3.3",
- "@radix-ui/react-collapsible": "1.1.12",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-context-menu": "2.2.16",
- "@radix-ui/react-dialog": "1.1.15",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.11",
- "@radix-ui/react-dropdown-menu": "2.1.16",
- "@radix-ui/react-focus-guards": "1.1.3",
- "@radix-ui/react-focus-scope": "1.1.7",
- "@radix-ui/react-form": "0.1.8",
- "@radix-ui/react-hover-card": "1.1.15",
- "@radix-ui/react-label": "2.1.7",
- "@radix-ui/react-menu": "2.1.16",
- "@radix-ui/react-menubar": "1.1.16",
- "@radix-ui/react-navigation-menu": "1.2.14",
- "@radix-ui/react-one-time-password-field": "0.1.8",
- "@radix-ui/react-password-toggle-field": "0.1.3",
- "@radix-ui/react-popover": "1.1.15",
- "@radix-ui/react-popper": "1.2.8",
- "@radix-ui/react-portal": "1.1.9",
- "@radix-ui/react-presence": "1.1.5",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-progress": "1.1.7",
- "@radix-ui/react-radio-group": "1.3.8",
- "@radix-ui/react-roving-focus": "1.1.11",
- "@radix-ui/react-scroll-area": "1.2.10",
- "@radix-ui/react-select": "2.2.6",
- "@radix-ui/react-separator": "1.1.7",
- "@radix-ui/react-slider": "1.3.6",
- "@radix-ui/react-slot": "1.2.3",
- "@radix-ui/react-switch": "1.2.6",
- "@radix-ui/react-tabs": "1.1.13",
- "@radix-ui/react-toast": "1.2.15",
- "@radix-ui/react-toggle": "1.1.10",
- "@radix-ui/react-toggle-group": "1.1.11",
- "@radix-ui/react-toolbar": "1.1.11",
- "@radix-ui/react-tooltip": "1.2.8",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2",
- "@radix-ui/react-use-effect-event": "0.0.2",
- "@radix-ui/react-use-escape-keydown": "1.1.1",
- "@radix-ui/react-use-is-hydrated": "0.1.0",
- "@radix-ui/react-use-layout-effect": "1.1.1",
- "@radix-ui/react-use-size": "1.1.1",
- "@radix-ui/react-visually-hidden": "1.2.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/react": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
- "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
- "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.4"
+ "react": "^19.2.8"
}
},
- "node_modules/react-is": {
- "version": "17.0.2",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
- "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true,
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
- "peer": true
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
},
"node_modules/react-refresh": {
"version": "0.17.0",
@@ -5425,75 +4531,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/react-remove-scroll": {
- "version": "2.7.2",
- "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
- "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
- "license": "MIT",
- "dependencies": {
- "react-remove-scroll-bar": "^2.3.7",
- "react-style-singleton": "^2.2.3",
- "tslib": "^2.1.0",
- "use-callback-ref": "^1.3.3",
- "use-sidecar": "^1.1.3"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/react-remove-scroll-bar": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
- "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
- "license": "MIT",
- "dependencies": {
- "react-style-singleton": "^2.2.2",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/react-style-singleton": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
- "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
- "license": "MIT",
- "dependencies": {
- "get-nonce": "^1.0.0",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
@@ -5509,18 +4546,70 @@
}
}
},
- "node_modules/redent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
- "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
- "dev": true,
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
- "indent-string": "^4.0.0",
- "strip-indent": "^3.0.0"
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
},
- "engines": {
- "node": ">=8"
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
}
},
"node_modules/require-from-string": {
@@ -5533,13 +4622,13 @@
}
},
"node_modules/rollup": {
- "version": "4.59.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
- "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/estree": "1.0.8"
+ "@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -5549,31 +4638,32 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.59.0",
- "@rollup/rollup-android-arm64": "4.59.0",
- "@rollup/rollup-darwin-arm64": "4.59.0",
- "@rollup/rollup-darwin-x64": "4.59.0",
- "@rollup/rollup-freebsd-arm64": "4.59.0",
- "@rollup/rollup-freebsd-x64": "4.59.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
- "@rollup/rollup-linux-arm64-gnu": "4.59.0",
- "@rollup/rollup-linux-arm64-musl": "4.59.0",
- "@rollup/rollup-linux-loong64-gnu": "4.59.0",
- "@rollup/rollup-linux-loong64-musl": "4.59.0",
- "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
- "@rollup/rollup-linux-ppc64-musl": "4.59.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
- "@rollup/rollup-linux-riscv64-musl": "4.59.0",
- "@rollup/rollup-linux-s390x-gnu": "4.59.0",
- "@rollup/rollup-linux-x64-gnu": "4.59.0",
- "@rollup/rollup-linux-x64-musl": "4.59.0",
- "@rollup/rollup-openbsd-x64": "4.59.0",
- "@rollup/rollup-openharmony-arm64": "4.59.0",
- "@rollup/rollup-win32-arm64-msvc": "4.59.0",
- "@rollup/rollup-win32-ia32-msvc": "4.59.0",
- "@rollup/rollup-win32-x64-gnu": "4.59.0",
- "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
"fsevents": "~2.3.2"
}
},
@@ -5620,6 +4710,25 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/serialize-error": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
+ "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sharp": {
+ "resolved": "stubs/sharp",
+ "link": true
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5649,15 +4758,25 @@
"license": "ISC"
},
"node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -5692,23 +4811,42 @@
"license": "MIT"
},
"node_modules/std-env": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
- "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
- "node_modules/strip-indent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
- "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
- "dev": true,
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
- "min-indent": "^1.0.0"
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
},
- "engines": {
- "node": ">=8"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
}
},
"node_modules/suspend-react": {
@@ -5727,37 +4865,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/tailwind-merge": {
- "version": "3.5.0",
- "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
- "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/dcastil"
- }
- },
- "node_modules/tailwindcss": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
- "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tapable": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
- "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
- }
- },
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
@@ -5804,9 +4911,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
- "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5814,14 +4921,14 @@
}
},
"node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -5886,14 +4993,24 @@
"node": ">=18"
}
},
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/troika-three-text": {
- "version": "0.52.4",
- "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
- "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
+ "version": "0.52.5",
+ "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz",
+ "integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==",
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.2",
- "troika-three-utils": "^0.52.4",
+ "troika-three-utils": "^0.52.5",
"troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1"
},
@@ -5902,9 +5019,9 @@
}
},
"node_modules/troika-three-utils": {
- "version": "0.52.4",
- "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
- "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
+ "version": "0.52.5",
+ "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz",
+ "integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.125.0"
@@ -5916,11 +5033,15 @@
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT"
},
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
},
"node_modules/tunnel-rat": {
"version": "0.1.2",
@@ -5959,6 +5080,18 @@
}
}
},
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -5973,6 +5106,99 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
@@ -6004,49 +5230,6 @@
"browserslist": ">= 4.21.0"
}
},
- "node_modules/use-callback-ref": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
- "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/use-sidecar": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
- "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
- "license": "MIT",
- "dependencies": {
- "detect-node-es": "^1.1.0",
- "tslib": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "peerDependencies": {
- "@types/react": "*",
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -6065,6 +5248,34 @@
"node": ">= 4"
}
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@@ -6140,6 +5351,21 @@
}
}
},
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
@@ -6335,9 +5561,9 @@
}
},
"node_modules/ws": {
- "version": "8.21.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
- "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6380,10 +5606,25 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/zustand": {
- "version": "5.0.12",
- "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
- "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "version": "5.0.15",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+ "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
@@ -6408,6 +5649,20 @@
"optional": true
}
}
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "stubs/sharp": {
+ "version": "0.0.0-cbm-stub",
+ "license": "MIT"
}
}
}
diff --git a/graph-ui/package.json b/graph-ui/package.json
index b023560e8..a38ba6faa 100644
--- a/graph-ui/package.json
+++ b/graph-ui/package.json
@@ -1,47 +1,92 @@
{
- "name": "graph-ui",
+ "name": "cbm-lens",
+ "version": "0.0.1",
"private": true,
- "version": "0.1.0",
"type": "module",
+ "description": "Lokales Verstehens-Frontend auf dem CBM-Stack (localhost, air-gapped per Default).",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
- "preview": "vite preview",
- "test": "vitest run",
- "test:watch": "vitest",
- "test:coverage": "vitest run --coverage"
- },
- "dependencies": {
- "@react-three/drei": "^10.7.0",
- "@react-three/fiber": "^9.5.0",
- "@react-three/postprocessing": "^3.0.4",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "lucide-react": "^0.577.0",
- "postprocessing": "^6.38.3",
- "radix-ui": "^1.4.3",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "tailwind-merge": "^3.5.0",
- "three": "~0.183.0"
+ "build": "tsc -b tsconfig.json tsconfig.node.json && vite build",
+ "test": "node --test tests/scaffold/*.test.mjs",
+ "test:acceptance": "node --test tests/scaffold/w*.test.mjs tests/scaffold/release-blockers.test.mjs",
+ "test:unit": "vitest run",
+ "verify": "node --test tests/scaffold/*.test.mjs",
+ "smoke:spike": "node tools/smoke-spike.mjs",
+ "smoke:w1": "node tools/smoke-w1.mjs",
+ "smoke:w1b": "node tools/smoke-w1b.mjs",
+ "smoke:w2a": "node tools/net-deny-gate.mjs --out verification/w2/netdeny.json -- node tools/smoke-w2a.mjs",
+ "smoke:w2b": "node tools/net-deny-gate.mjs --out verification/w2/netdeny-w2b.json -- node tools/smoke-w2b.mjs",
+ "smoke:w3": "node tools/net-deny-gate.mjs --out verification/w3/netdeny.json -- node tools/smoke-w3.mjs",
+ "smoke:w4a": "node tools/net-deny-gate.mjs --out verification/w4/netdeny.json -- node tools/smoke-w4a.mjs",
+ "smoke:w4b": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4b.json -- node tools/smoke-w4b.mjs",
+ "smoke:w4c": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4c.json -- node tools/smoke-w4c.mjs",
+ "smoke:w4d": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4d.json -- node tools/smoke-w4d.mjs",
+ "smoke:w4e": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4e.json -- node tools/smoke-w4e.mjs",
+ "smoke:w5a": "node tools/net-deny-gate.mjs --out verification/w5/netdeny-w5a.json -- node tools/smoke-w5a.mjs",
+ "smoke:w5b": "node tools/net-deny-gate.mjs --out verification/w5/netdeny-w5b.json -- node tools/smoke-w5b.mjs",
+ "smoke:w5c": "node tools/net-deny-gate.mjs --out verification/w5/netdeny-w5c.json -- node tools/smoke-w5c.mjs",
+ "smoke:w6": "node tools/net-deny-gate.mjs --out verification/w6/netdeny.json -- node tools/smoke-w6-full.mjs",
+ "smoke:w7a": "node tools/net-deny-gate.mjs --out verification/w7/netdeny.json -- node tools/smoke-w7a.mjs",
+ "smoke:w7b": "node tools/net-deny-gate.mjs --out verification/w7/netdeny-w7b.json -- node tools/smoke-w7b.mjs",
+ "smoke:w7c": "node tools/net-deny-gate.mjs --out verification/w7/netdeny-w7c.json -- node tools/smoke-w7c.mjs",
+ "smoke:w8": "node tools/net-deny-gate.mjs --out verification/w8/netdeny.json -- node tools/smoke-w8.mjs",
+ "smoke:w8b": "node tools/net-deny-gate.mjs --out verification/w8b/netdeny.json -- node tools/smoke-w8b.mjs",
+ "smoke:w8c": "node tools/net-deny-gate.mjs --out verification/w8c/netdeny.json -- node tools/smoke-w8c.mjs",
+ "smoke:w9": "node tools/net-deny-gate.mjs --out verification/w9/netdeny-w9.json -- node tools/smoke-w9.mjs",
+ "smoke:w10": "node tools/net-deny-gate.mjs --out verification/w10/netdeny.json -- node tools/smoke-w10.mjs",
+ "smoke:w10b": "node tools/net-deny-gate.mjs --out verification/w10b/netdeny.json -- node tools/smoke-w10b.mjs",
+ "smoke:w11a": "node tools/net-deny-gate.mjs --out verification/w11/netdeny.json -- node tools/smoke-w11a.mjs",
+ "smoke:w11b": "node tools/net-deny-gate.mjs --out verification/w11/netdeny-w11b.json -- node tools/smoke-w11b.mjs",
+ "smoke:w12": "node tools/net-deny-gate.mjs --out verification/w12/netdeny.json -- node tools/smoke-w12.mjs",
+ "smoke:w12a": "node tools/net-deny-gate.mjs --out verification/w12a/netdeny.json -- node tools/smoke-w12a.mjs",
+ "smoke:w13": "node tools/net-deny-gate.mjs --out verification/w13/netdeny.json -- node tools/smoke-w13.mjs",
+ "smoke:w14": "node tools/net-deny-gate.mjs --out verification/w14/netdeny.json -- node tools/smoke-w14.mjs",
+ "smoke:w15": "node tools/net-deny-gate.mjs --out verification/w15/netdeny.json -- node tools/smoke-w15.mjs",
+ "demo:record": "node tools/record-demo.mjs",
+ "gate:release": "node tools/release-gate.mjs",
+ "check:freshclone": "node tools/freshclone-check.mjs",
+ "check:style": "node tools/style-gate.mjs",
+ "check:promises": "node tools/promise-scan.mjs",
+ "eval:llm": "node tools/eval-llm.mjs",
+ "eval:check": "node tools/eval-check.mjs",
+ "verify:models": "node tools/verify-models.mjs"
},
"devDependencies": {
- "@tailwindcss/vite": "^4.2.1",
- "@testing-library/jest-dom": "^6.6.0",
- "@testing-library/react": "^16.1.0",
- "@types/react": "^19.0.0",
- "@types/react-dom": "^19.0.0",
- "@types/three": "~0.183.0",
- "@vitejs/plugin-react": "^4.3.0",
- "jsdom": "^25.0.0",
- "tailwindcss": "^4.1.0",
- "typescript": "^5.7.0",
- "vite": "^6.4.3",
- "vitest": "^4.1.11"
+ "@types/node": "20.19.43",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.5",
+ "@types/three": "0.183.1",
+ "@vitejs/plugin-react": "4.7.0",
+ "jsdom": "25.0.1",
+ "monaco-editor": "0.56.0",
+ "playwright": "1.62.1",
+ "three-stdlib": "2.36.1",
+ "typescript": "5.9.3",
+ "vite": "6.4.3",
+ "vitest": "4.1.11"
},
"overrides": {
- "form-data": ">=4.0.6",
- "@babel/core": ">=7.29.6",
- "nanoid": "^3.3.17"
+ "sharp": "$sharp",
+ "monaco-editor": {
+ "dompurify": "3.4.16"
+ },
+ "onnxruntime-node@1.24.3": {
+ "adm-zip": "0.6.1",
+ "global-agent": "4.1.3"
+ }
+ },
+ "dependencies": {
+ "@huggingface/transformers": "4.2.0",
+ "@react-three/drei": "10.7.7",
+ "@react-three/fiber": "9.5.0",
+ "@react-three/postprocessing": "3.0.4",
+ "postprocessing": "6.38.3",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "react-markdown": "10.1.0",
+ "remark-gfm": "4.0.1",
+ "sharp": "file:./stubs/sharp",
+ "three": "0.183.2",
+ "yaml": "2.8.3"
}
}
diff --git a/graph-ui/spike/index.html b/graph-ui/spike/index.html
new file mode 100644
index 000000000..88b0cf122
--- /dev/null
+++ b/graph-ui/spike/index.html
@@ -0,0 +1,111 @@
+
+
+
+
+CodeAtlasWeb W0 Spike: Serverquelle in read-only Monaco
+
+
+
+
+
+ CODEATLAS W0 SPIKE
+ quelle /rpc get_code_snippet
+ symbol ...
+ datei ...
+ modus read-only
+
+
+
lade ...
+
+
+
+
+
diff --git a/graph-ui/spike/spike.js b/graph-ui/spike/spike.js
new file mode 100644
index 000000000..eefe2b5f7
--- /dev/null
+++ b/graph-ui/spike/spike.js
@@ -0,0 +1,210 @@
+// W0-Spike. Wegwerf-Beweiscode, NICHT die Produktarchitektur.
+//
+// Ablauf: Symbol aus den Query-Parametern (?project=...&qn=...) holen, den
+// Quelltext über POST /rpc (MCP tools/call get_code_snippet) beim gebauten
+// C-Server abfragen, ihn read-only in Monaco rendern und genau eine
+// Decoration auf die Zeile mit dem validateUser(-Aufruf setzen.
+//
+// Monaco kommt aus /node_modules/monaco-editor/min/vs (AMD-Loader), kein CDN.
+
+(function () {
+ 'use strict';
+
+ var MONACO_VS = '/node_modules/monaco-editor/min/vs';
+ var CALL_NEEDLE = 'validateUser(';
+
+ var statusEl = document.getElementById('status');
+
+ function setStatus(text, failed) {
+ statusEl.textContent = text;
+ statusEl.className = failed ? 'failed' : '';
+ }
+
+ function fail(stage, err) {
+ var message = (err && err.message) ? err.message : String(err);
+ setStatus('FEHLER in ' + stage + ': ' + message, true);
+ window.__spike = {
+ ready: false,
+ stage: stage,
+ error: message,
+ monacoReadOnly: false,
+ decorationCount: 0,
+ sourceLength: 0
+ };
+ }
+
+ function params() {
+ var q = new URLSearchParams(window.location.search);
+ return {
+ project: q.get('project') || '',
+ qn: q.get('qn') || ''
+ };
+ }
+
+ // POST /rpc im MCP-Format. Der Mini-Proxy vor dieser Seite reicht den
+ // Aufruf serverseitig an den C-Server weiter (dessen Origin-Prüfung
+ // lässt eine fremde Seite nicht direkt an /rpc).
+ function callTool(name, args) {
+ return fetch('/rpc', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: name, arguments: args }
+ })
+ }).then(function (res) {
+ if (!res.ok) {
+ throw new Error('/rpc antwortete mit HTTP ' + res.status);
+ }
+ return res.json();
+ }).then(function (body) {
+ if (body.error) {
+ throw new Error('/rpc-Fehler: ' + JSON.stringify(body.error));
+ }
+ var content = body.result && body.result.content;
+ if (!content || !content.length || typeof content[0].text !== 'string') {
+ throw new Error('/rpc lieferte kein content[0].text');
+ }
+ return JSON.parse(content[0].text);
+ });
+ }
+
+ // Die Call-Site suchen: die Zeile, die validateUser( aufruft, nicht der
+ // Import und nicht die Deklaration selbst.
+ function findCallSiteLine(source) {
+ var lines = source.split('\n');
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i];
+ if (line.indexOf(CALL_NEEDLE) === -1) { continue; }
+ var trimmed = line.trim();
+ if (trimmed.indexOf('import') === 0) { continue; }
+ if (/\bfunction\s+validateUser\s*\(/.test(trimmed)) { continue; }
+ return i + 1; // Monaco zählt Zeilen ab 1
+ }
+ return 0;
+ }
+
+ function loadMonaco() {
+ return new Promise(function (resolve, reject) {
+ if (typeof window.require !== 'function' || !window.require.config) {
+ reject(new Error('AMD-Loader aus ' + MONACO_VS + '/loader.js fehlt'));
+ return;
+ }
+ window.require.config({ paths: { vs: MONACO_VS } });
+ window.require(['vs/editor/editor.main'], function () {
+ resolve(window.monaco);
+ }, function (err) {
+ reject(new Error('Monaco ließ sich nicht laden: ' + (err && err.message ? err.message : String(err))));
+ });
+ });
+ }
+
+ function render(monaco, snippet) {
+ monaco.editor.defineTheme('atlas-phosphor', {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editor.background': '#06090b',
+ 'editorGutter.background': '#0b1013',
+ 'editorLineNumber.foreground': '#2f4a40',
+ 'editorLineNumber.activeForeground': '#33ff99'
+ }
+ });
+
+ var editor = monaco.editor.create(document.getElementById('editor'), {
+ value: snippet.source,
+ language: 'typescript',
+ theme: 'atlas-phosphor',
+ readOnly: true,
+ domReadOnly: true,
+ glyphMargin: true,
+ automaticLayout: true,
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ renderLineHighlight: 'none',
+ fontSize: 14,
+ lineNumbers: function (n) {
+ // Zeilennummern der echten Datei, nicht des Ausschnitts.
+ return String((snippet.start_line || 1) + n - 1);
+ }
+ });
+
+ var callLine = findCallSiteLine(snippet.source);
+ var ids = [];
+ if (callLine > 0) {
+ ids = editor.deltaDecorations([], [{
+ range: new monaco.Range(callLine, 1, callLine, 1),
+ options: {
+ isWholeLine: true,
+ className: 'atlas-callsite-line',
+ glyphMarginClassName: 'atlas-callsite-glyph',
+ glyphMarginHoverMessage: { value: '1 Aufruf: validateUser' }
+ }
+ }]);
+ }
+
+ return { editor: editor, decorationIds: ids, callLine: callLine };
+ }
+
+ function main() {
+ var p = params();
+ if (!p.project || !p.qn) {
+ fail('parameter', new Error('project und qn sind Pflicht-Query-Parameter'));
+ return;
+ }
+
+ document.getElementById('hdr-qn').textContent = p.qn;
+ setStatus('hole Quelltext über /rpc get_code_snippet ...');
+
+ var snippet = null;
+
+ callTool('get_code_snippet', { project: p.project, qualified_name: p.qn })
+ .then(function (result) {
+ snippet = result;
+ if (typeof snippet.source !== 'string' || snippet.source.length === 0) {
+ throw new Error('get_code_snippet lieferte kein source-Feld');
+ }
+ document.getElementById('hdr-file').textContent =
+ (snippet.file_path || '?') + ':' + (snippet.start_line || '?');
+ setStatus('lade Monaco aus ' + MONACO_VS + ' ...');
+ return loadMonaco();
+ })
+ .then(function (monaco) {
+ var rendered = render(monaco, snippet);
+ var readOnly = rendered.editor.getOption(monaco.editor.EditorOption.readOnly);
+
+ window.__spike = {
+ ready: true,
+ monacoReadOnly: readOnly === true,
+ decorationCount: rendered.decorationIds.length,
+ sourceLength: snippet.source.length,
+ qualifiedName: snippet.qualified_name || p.qn,
+ filePath: snippet.file_path || '',
+ startLine: snippet.start_line || 0,
+ endLine: snippet.end_line || 0,
+ callSiteLine: rendered.callLine,
+ callSiteFileLine: rendered.callLine > 0
+ ? (snippet.start_line || 1) + rendered.callLine - 1
+ : 0
+ };
+
+ setStatus(
+ 'ok: ' + snippet.source.length + ' Zeichen vom Server, read-only=' +
+ (readOnly === true) + ', Decorations=' + rendered.decorationIds.length +
+ ' auf Datei-Zeile ' + window.__spike.callSiteFileLine
+ );
+ })
+ .catch(function (err) {
+ fail('spike', err);
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', main);
+ } else {
+ main();
+ }
+})();
diff --git a/graph-ui/src/App.test.tsx b/graph-ui/src/App.test.tsx
deleted file mode 100644
index f6437c40d..000000000
--- a/graph-ui/src/App.test.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-/* @vitest-environment jsdom */
-import "@testing-library/jest-dom/vitest";
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { App } from "./App";
-import { messages } from "./lib/i18n";
-
-vi.mock("./components/GraphTab", () => ({ GraphTab: () => null }));
-vi.mock("./components/StatsTab", () => ({ StatsTab: () => null }));
-vi.mock("./components/ControlTab", () => ({ ControlTab: () => null }));
-vi.mock("./lib/i18n", async (importOriginal) => {
- const actual = await importOriginal();
- return { ...actual, useUiMessages: () => messages.en };
-});
-
-describe("App", () => {
- afterEach(() => {
- cleanup();
- vi.unstubAllGlobals();
- window.history.replaceState(null, "", "/");
- });
-
- it("shows the serving binary version", async () => {
- vi.stubGlobal("fetch", vi.fn(async () =>
- new Response(JSON.stringify({ lang: "en", version: "0.10.8" }), {
- status: 200,
- headers: { "Content-Type": "application/json" },
- }),
- ));
-
- render();
-
- expect(await screen.findByText("v0.10.8")).toBeVisible();
- });
-
- it("hides the version when the config has no string version", async () => {
- const fetchMock = vi.fn(async () =>
- new Response(JSON.stringify({ lang: "en", version: 108 }), { status: 200 }),
- );
- vi.stubGlobal("fetch", fetchMock);
-
- render();
-
- await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
- expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
- });
-
- it("hides the version when the config request fails", async () => {
- const fetchMock = vi.fn(async () => {
- throw new Error("offline");
- });
- vi.stubGlobal("fetch", fetchMock);
-
- render();
-
- await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
- expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
- });
-});
\ No newline at end of file
diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx
index a0d84b0b6..daebffe35 100644
--- a/graph-ui/src/App.tsx
+++ b/graph-ui/src/App.tsx
@@ -1,159 +1,5033 @@
-import { useCallback, useEffect, useState } from "react";
-import { GraphTab } from "./components/GraphTab";
-import { StatsTab } from "./components/StatsTab";
-import { ControlTab } from "./components/ControlTab";
-import type { TabId } from "./lib/types";
-import { useUiMessages } from "./lib/i18n";
+/**
+ * Die Verdrahtung: Projekt aus der Adresszeile, Baum aus dem Graphen, Datei in
+ * den Reader.
+ *
+ * Hier steht der Zustand und sonst nichts Darstellendes. Das Chrome
+ * (src/app/AtlasChrome.tsx) bekommt fertige Werte, der Reader
+ * (src/reader/MonacoReader.tsx) bekommt ein fertiges Dokument. Diese Datei ist
+ * die einzige, die weiss, dass es einen Server gibt.
+ *
+ * Drei Entscheidungen, die man sonst raten muesste:
+ *
+ * 1. **Das Projekt kommt aus `?project=`.** Fehlt der Parameter, wird das erste
+ * Projekt aus `list_projects` genommen. Gibt es keins, sagt die Oberflaeche
+ * "no project" und zeigt keinen Baum, statt einen leeren zu zeigen: ein
+ * leerer Baum waere die Behauptung, das Projekt sei leer.
+ * 2. **Der Baum wird beim Laden aufgeklappt.** `/api/tree` antwortet je Anfrage
+ * mit einer Ebene, und ein Explorer, der mit zwei zugeklappten Ordnern
+ * startet, zeigt von einem indizierten Projekt so gut wie nichts. Es wird
+ * breitenweise nachgeladen, bis ein Deckel erreicht ist; was danach noch
+ * offen ist, bleibt zugeklappt und laedt beim Klick. Der Deckel wird
+ * genannt, wenn er greift.
+ * 3. **Eine Datei kommt ueber ihren Modul-Knoten.** Warum es keinen anderen Weg
+ * gibt und was das kostet, steht in src/reader/file-source.ts.
+ *
+ * Dazu kommt seit W2b die Caret-Strecke des Twins, und die hat drei eigene
+ * Entscheidungen:
+ *
+ * 4. **Aufloesen wird entprellt, Hervorheben nicht.** Ein Caret, der durch eine
+ * Funktion wandert, soll das Badge sofort mitnehmen, aber nicht bei jedem
+ * Tastendruck den Graphen fragen. Also wandert `caretLine` sofort und
+ * `settledCaret` nach 250 ms, und nur das zweite loest auf.
+ * 5. **Dasselbe Symbol wird nicht zweimal geholt.** Der Cache in
+ * src/twin/ir-cache.ts schluesselt ueber den qualifizierten Namen und legt
+ * gleichzeitige Anfragen zusammen; hier kommt nur die Regel dazu, bei
+ * unveraendertem Subjekt gar nicht erst in den Ladezustand zu gehen, damit
+ * das Panel beim Wandern des Carets nicht blinkt.
+ * 6. **Folgen wechselt das Subjekt sofort und laesst den Editor nachkommen.**
+ * Der Kopf des Panels nennt ab dem Klick das neue Symbol und der Koerper
+ * sagt, dass gelesen wird. Die Fakten kommen erst, wenn der Caret in der
+ * Zieldatei aufgeloest ist: eine IR, die aus dem Klickziel statt aus einer
+ * Aufloesung gebaut waere, traege eine erfundene Symbolart, und "This is a
+ * piece of code" ueber einer Funktion ist genau die Sorte kleiner Luege,
+ * gegen die dieses Panel gebaut ist.
+ *
+ * Seit W3 kommen die Galaxie und die Bedeutungssuche dazu, mit drei weiteren
+ * Entscheidungen:
+ *
+ * 7. **Der Fokus laeuft in beide Richtungen ueber genau einen Schluessel.**
+ * Das Twin-Subjekt reist als qualifizierter Name zur Galaxie, die Galaxie
+ * schickt einen angeklickten Knoten als Ziel zurueck durch denselben
+ * `followTarget`, den auch der Twin benutzt. Es gibt also keinen zweiten
+ * Navigationsweg, den man vergessen koennte zu pflegen, und keine zweite
+ * Stelle, an der ein Symbol identifiziert wird.
+ * 8. **Ein Klick in die Galaxie behauptet nichts ueber das Symbol.** Er
+ * oeffnet die Datei an der gemeldeten Zeile; was dort steht, sagt die
+ * Aufloesung, so wie beim Folgen aus dem Twin. Der Grund ist derselbe wie
+ * in Entscheidung 6.
+ * 9. **Getippt wird ohne Anfrage, gefragt wird nach 200 ms Ruhe.** Die
+ * Kommandozeile ist eine Suche und kein Terminal: sie schickt keine Zeile
+ * ab, sie zeigt waehrend des Tippens, was der Index kennt. Ohne die
+ * Entprellung waere jede Taste ein Sweep ueber den Graphen.
+ *
+ * Seit W4a kommen die Einstiegsmodi dazu, und mit ihnen vier weitere:
+ *
+ * 10. **Die Frage nach dem Warum ist eine Flaeche, kein Dialog.** Sie fuellt
+ * den leeren Editorbereich, wenn keine Datei offen ist und dieser Browser
+ * fuer dieses Projekt noch keine Antwort kennt. Ein Modal beim ersten
+ * Oeffnen wuerde die Oberflaeche und jeden automatisierten Lauf durch sie
+ * blockieren; das ist die Begruendung des Referenzprojekts und sie gilt
+ * hier unveraendert.
+ * 11. **Eine Antwort setzt genau eine Sache und wird dann vergessen.** Sie
+ * setzt das Leseprofil des Twins (Tiefe und Linsen, Zuordnung in
+ * why/why-model.ts) und wird gespeichert, damit die Frage nicht wieder
+ * kommt. Niemand fragt den Intent spaeter zurueck: ein weitergereichter
+ * Intent waere ein Modus, und ein Modus ist ein zweites Produkt im ersten.
+ * 12. **Ein Schritt bewegt den Reader ueber dieselbe Strecke wie ein Klick.**
+ * Ein Symbolschritt geht durch `followTarget`, ein Dateischritt durch
+ * `openFile`. Es gibt keinen zweiten Navigationsweg fuer Fuehrungen, also
+ * auch keine zweite Stelle, an der ein Symbol identifiziert wird, und der
+ * Twin folgt einem Schritt aus demselben Grund, aus dem er einem Klick
+ * folgt.
+ * 13. **Die stille Anzeige zaehlt Wege, nie Zeit.** Beim Betreten eines
+ * Schrittes und beim Folgen einer Zeile wird ein Checklisten-Item als
+ * besucht vermerkt. Verweildauer ist keine Evidenz, und die Statusleiste
+ * zeigt gar keinen Zaehler, solange es keine Checkliste zu zaehlen gibt,
+ * statt "0 of 0" zu behaupten.
+ *
+ * Seit W4b sind die beiden letzten Karten der Frage scharf, und mit ihnen
+ * kommen drei weitere Entscheidungen:
+ *
+ * 14. **Der BUG-Assistent uebernimmt das Subjekt des Twins und schiebt
+ * nichts zurueck.** Er liest neu, wenn ein anderes Symbol vor dem Leser
+ * steht, und veroeffentlicht selbst nichts: wer zwei Pfade vergleicht, hat
+ * nicht darum gebeten, dass Reader, Twin und Galaxie unter ihm wegwandern.
+ * Nur ein Klick auf einen Hop navigiert, und der loest den Namen in diesem
+ * Moment auf, statt einen gespeicherten Verweis zu benutzen.
+ * 15. **Ein kaputter Ref wird hier abgelehnt und nicht dort.** `detect_changes`
+ * nimmt ein unbekanntes `since` kommentarlos an und antwortet ueber den
+ * Arbeitsbaum. Ein Tippfehler ergaebe also eine plausible Antwort auf eine
+ * andere Frage, und deshalb prueft `refRejection` die Form im Browser und
+ * der Aufruf unterbleibt. Der Beweislauf zaehlt die Anfragen am Proxy und
+ * liest diese Zahl als die Behauptung.
+ * 16. **Die beiden Flaechen schliessen einander aus.** Beide brauchen den
+ * ganzen Platz ueber dem Editor, und zwei ganze Antworten uebereinander
+ * waeren keine. Escape schliesst, was offen ist; das [a]tlas-Menue macht
+ * beim Oeffnen der einen die andere zu.
+ *
+ * Seit W4e reicht diese Datei eine Antwort weiter, die sie bisher wegwarf:
+ *
+ * 17. **Der Walk bleibt liegen, solange seine Fuehrung laeuft.** `getClosure`
+ * liefert einen Graphen; daraus eine Schrittliste zu machen und den Graphen
+ * zu vergessen hiess, die Tiefe wegzuwerfen, die in ihm steht. Also haelt
+ * `walk` ihn, und das Graph-Panel zeigt ihn als Hierarchie ab dem
+ * gewaehlten Einstiegspunkt. Er endet, wenn die Fuehrung endet: ein Bild
+ * einer beendeten Frage waere eine Antwort, nach der niemand mehr fragt.
+ */
+import type { JSX, KeyboardEvent } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-const TAB_IDS: TabId[] = ["graph", "stats", "control"];
+import AtlasChrome, { CLOSE_COMMAND_SEARCH_EVENT, OPEN_COMMAND_SEARCH_EVENT } from './app/AtlasChrome';
+import { AtlasTreeIndexDetails } from './app/AtlasTree';
+import ArchitecturePanel from './architecture/ArchitecturePanel';
+import AdrWorkspace from './adr/AdrWorkspace';
+import SelectionContextPanel, { type SelectionScope } from './why/SelectionContext';
+import FileImpactSummary from './impact/FileImpactSummary';
+import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './architecture/SourceEvidenceDrawer';
+import { setUiLogProject } from './app/ui-log-install';
+import DiagnosticsPanel from './diagnostics/DiagnosticsPanel';
+import { resolveRepositorySelection, useRepositorySnapshot } from './architecture/repository-source';
+import ActivityPanel from './agents/ActivityPanel';
+import WelcomePanel from './app/WelcomePanel';
+import BrowserChatDock from './browser-ai/BrowserChatDock';
+import { switchKeepingAgent } from './browser-ai/agent-handover';
+import { browserChatHistoryProjectKey } from './browser-ai/chat-history-cache';
+import type { BrowserChatAttachment } from './browser-ai/BrowserChatDock';
+import { browserGraphContext } from './browser-ai/graph-context';
+import { readerChatContext, type ReaderSourceOrigin } from './browser-ai/reader-chat-context';
+import type { BrowserChatContext } from './browser-ai/chat-model';
+import { selectionEvidenceContext } from './galaxy/selection-evidence';
+import SystemWorkspace from './system/SystemWorkspace';
+import type { Workspace, Guidance } from './app/workspace-strings';
+import { allowedWorkspace, initialWorkspace, workspaceStrings as workspaceText } from './app/workspace-strings';
+import { experimentalAgentsEnabled } from './app/feature-flags';
+import type { Chip, MenuExtra, MenuWiring, TabDescriptor } from './app/AtlasChrome';
+import { AtlasApi, TREE_ROUTE } from './app/atlas-api';
+import { ATLAS_BUILD_SUFFIX, ATLAS_VERSION } from './app/build-info';
+import { messages } from './i18n/messages';
+import {
+ KEY_LISTENER_OPTIONS,
+ menuShortcutFor,
+ tourKeyForEvent,
+} from './app/keyboard';
+import { markHandled } from './app/key-probe';
+import { WIRED_MENU_SHORTCUTS } from './app/shortcuts';
+import { baseName, pathSegments } from './app/module-qn';
+import HelpOverlay from './help/HelpOverlay';
+import {
+ directoryPaths,
+ EMPTY_COVERAGE,
+ flattenTree,
+ mergeCoverageIntoLevels,
+ moveCursor,
+ parentPath,
+ treeIntent,
+} from './app/tree-model';
+import type { CoverageIndex, CoverageState, TreeLevel, TreeRow } from './app/tree-model';
+import { loadCoverage, loadPathCoverage } from './app/coverage-source';
+import { keepsReadyTwinForTarget } from './app/ready-twin-target';
+import type { CoveragePathAnswer } from './app/tree-model';
+import {
+ freshnessNote,
+ freshnessNoteNeeded,
+ partialFileNote,
+ readerUnavailableNote,
+} from './app/coverage-strings';
+import { RpcIntelligenceClient } from './provider/rpc-client';
+import { CbmRpcProvider, symbolKindOf } from './provider/cbm-rpc-provider';
+import MonacoReader from './reader/MonacoReader';
+import type { ReaderStatus, ReaderSelection } from './reader/MonacoReader';
+import { FileNotReadableError, loadFileDocument, READER_RPC_TOOL } from './reader/file-source';
+import type { ReaderDocument } from './reader/file-source';
+import { badgesForLines } from './core/step-badge-decorator';
+import type { SymbolRef } from './core/focus-protocol';
+import type { SemanticIR } from './core/semantic-ir';
+import { buildIr } from './ir/semantic-ir-builder';
+import { PseudocodeView, type TwinStatus } from './twin/TwinPanel';
+import SelectedCodePanel from './twin/SelectedCodePanel';
+import { loadFileSymbols, type FileSymbolResult } from './twin/selected-code-context';
+import { inspectorChatContext, matchingInspectorIr, type SelectedCodeSnapshot } from './twin/selected-code-snapshot';
+import { IrCache } from './twin/ir-cache';
+import { ATLAS_WORKSPACE_ROOT, twinLocationOf, twinTargetOf, workspacePathOf } from './twin/twin-target';
+import GalaxyPanel from './galaxy/GalaxyPanel';
+import { targetRefOfNode } from './galaxy/galaxy-model';
+import { hierarchySymbolOf, useSelectionHierarchy } from './galaxy/selection-hierarchy';
+import { markedSourceRange, symbolMatchesReader } from './galaxy/reader-graph-focus';
+import type { GraphData, GraphNode } from './galaxy/types';
+import SearchOverlay from './search/SearchOverlay';
+import type { SearchOverlayStatus } from './search/SearchOverlay';
+import {
+ SEARCH_DEBOUNCE_MS,
+ SEARCH_MIN_QUERY,
+ findByMeaning,
+ searchByMeaning,
+} from './search/find-by-meaning';
+import type { RankedHit } from './search/semantic-search';
+import {
+ commandExamplesFor,
+ exampleSymbolOf,
+} from './search/command-examples';
+import { EMPTY_LOCAL_INDEX, localCandidates, rankLocalCandidates, settledSearchHits } from './search/local-suggestions';
+import type { LocalIndex } from './search/local-suggestions';
+import {
+ isSearchable,
+ MAX_SEARCH_ROWS,
+ moveSelection,
+ overlayIntent,
+ searchHeadline,
+ searchRows,
+} from './search/overlay-model';
+import type { SearchRow, SearchRowSource } from './search/overlay-model';
+import { Facet } from './twin/presentation-profile';
+import type { PresentationProfile } from './twin/presentation-profile';
+import type { TwinRow } from './twin/twin-view-model';
+import WhyPanel from './why/WhyPanel';
+import { WHY_MENU_LABEL } from './why/why-model';
+import type { WhyIntent } from './why/why-model';
+import { readWhyAnswer, recordWhyAnswer } from './why/why-store';
+import type { WhyAnswer } from './why/why-store';
+import TourCard from './tours/TourCard';
+import { generateProjectTour } from './tours/tour-source';
+import { entryWalkTour } from './tours/entry-walk';
+import type { ActiveTour } from './tours/entry-walk';
+import { isLastStep, playerIntent, stepMove } from './tours/tour-player';
+import { markableItemId } from './tours/tour-model';
+import { getClosure } from './provider/closure';
+import type { ClosureResult } from './provider/closure';
+import FlowOverlay from './pseudocode/FlowOverlay';
+import { buildFlowView, FLOW_CLOSURE_CAP, FLOW_CLOSURE_DEPTH } from './pseudocode/flow-view';
+import type { FlowView } from './pseudocode/flow-view';
+import { buildImportsGroup } from './pseudocode/imports-group';
+import type { ImportsGroup } from './pseudocode/imports-group';
+import { fileImportsFor } from './pseudocode/imports-source';
+import { buildPseudocode, closureDocumentOf } from './pseudocode/pseudocode-builder';
+import type { PseudocodeDocument, PseudocodeSourceRef } from './pseudocode/pseudocode-builder';
+import { FLOW_LOADING, FLOW_UNAVAILABLE } from './pseudocode/pseudocode-strings';
+import EntryPointDialog from './entry/EntryPointDialog';
+import type { EntrySearchStatus } from './entry/EntryPointDialog';
+import { entryHeadline, entryRows, routeNote } from './entry/entry-model';
+import type { ArchitectureOverviewDto, SymbolSearchHit } from './core/intelligence-provider';
+import {
+ browserStore,
+ exploredLabel,
+ markVisited,
+ readUnderstanding,
+ totalMarks,
+ understandingOf,
+} from './checklist/understanding-store';
+import type { UnderstandingRecord } from './checklist/understanding-store';
+import {
+ TWIN_EMPTY_HINT,
+ TWIN_EMPTY_MESSAGE,
+ TWIN_FILE_NOT_INDEXED,
+ TWIN_FILE_NOT_INDEXED_HINT,
+ TWIN_LOADING,
+ TWIN_LOAD_FAILED,
+} from './twin/strings';
+import BugWizard from './traces/BugWizard';
+import type { BugWizardStatus } from './traces/BugWizard';
+import { bugPaths, resolveHop } from './traces/bug-paths';
+import type { BugPathNode, BugPathsDto } from './traces/bug-paths';
+import { BUG_WIZARD_MENU_LABEL } from './traces/bug-wizard-strings';
+import { SIDECAR_ORIGIN } from './llm/sidecar';
+import type { CacheModel, SidecarReading, SidecarState } from './llm/sidecar';
+import AtlasChatPanel from './chat/AtlasChatPanel';
+import { askAtlas } from './chat/ask-atlas';
+import type { ChatTurn } from './chat/ask-atlas';
+import { commandIntent } from './chat/command-intent';
+import {
+ CHAT_HINT_OFF,
+ CHAT_HINT_READY,
+} from './chat/chat-strings';
+import ExplainZone from './layout/ExplainZone';
+import Splitter from './layout/Splitter';
+import { explainTabs } from './layout/explain-tabs';
+import type { ExplainTabId, ExplainTabState } from './layout/explain-tabs';
+import { lineCommandOf } from './layout/layout-command';
+import {
+ LAYOUT_KEYS,
+ clampLayout,
+ clampLayoutValue,
+ defaultLayout,
+ layoutBounds,
+ layoutStorageKey,
+ readLayout,
+ sameLayout,
+ writeLayout,
+} from './layout/layout-model';
+import type { LayoutFrame, LayoutKey, LayoutSizes } from './layout/layout-model';
+import { NEIGHBOR_DEPTHS, NEIGHBOR_DEPTH_DEFAULT } from './compiler/fact-recipes';
+import type { NeighborDepth, ObservedFact, SubjectCandidate } from './compiler/fact-recipes';
+import { modelClassOf } from './compiler/card-compiler';
+import type { CardSource } from './compiler/card-compiler';
+import { applyRefinement } from './pseudocode/refine';
+import { readLlmPolicy } from './llm/policy';
+import type { PolicyReading } from './llm/policy';
+import { resolveLlmState } from './llm/llm-state';
+import { llmChipValue } from './llm/strings';
+import SettingsPanel from './settings/SettingsPanel';
+import ConfigPanel from './settings/ConfigPanel';
+import type { SettingsMeasurement } from './settings/SettingsPanel';
+import AddProjectIndexDialog from './projects/AddProjectIndexDialog';
+import ProjectSwitcher from './projects/ProjectSwitcher';
+import type { ProjectsSource } from './projects/ProjectsPanel';
+import { ProjectWindows, useParamInAddress, type ProjectWindow } from './app/project-windows';
+import { MODEL_SUGGESTIONS, fetchCommand } from './settings/model-catalog';
+import { modelKey, readModelPreference, recordModelPreference } from './settings/model-preference';
+import {
+ DEFAULT_GRAPH_DISPLAY,
+ displayKey,
+ isDefaultDisplay,
+ loadDisplaySettings,
+ saveDisplaySettings,
+} from './galaxy/density';
+import type { GraphDisplaySettings } from './galaxy/density';
+import { bridgePortFromSearch, useAgentStream } from './agents/agent-source';
+import { agentsMenuLabel } from './agents/agent-strings';
-interface RouteState {
- tab: TabId;
- project: string | null;
+/**
+ * Wie viele Ebenen beim Start von selbst nachgeladen werden.
+ *
+ * Der Deckel ist da, weil jede Ebene eine eigene Anfrage ist: ein Repository
+ * mit tausend Verzeichnissen waere sonst tausend Anfragen beim ersten Bild.
+ * Vierzig Ebenen decken die Fixtures und jedes normale Projekt ab; was darueber
+ * liegt, klappt der Benutzer selbst auf und die Oberflaeche sagt es ihm.
+ */
+export const EAGER_LEVEL_BUDGET = 40;
+
+/**
+ * Wie lange der Caret stehen muss, bevor gefragt wird.
+ *
+ * 250 ms ist die Spanne, in der ein Leser mit den Pfeiltasten durch einen
+ * Rumpf geht, ohne stehenzubleiben. Kuerzer, und jede Zeile waere eine Frage an
+ * den Graphen; laenger, und das Panel haengt spuerbar hinter dem Caret.
+ */
+export const TWIN_CARET_DEBOUNCE_MS = 250;
+
+/**
+ * Start with the supported facts at depth two. Runtime and Changes remain
+ * opt-in because their unavailable states otherwise add noise to the panel.
+ */
+export const TWIN_PROFILE: PresentationProfile = {
+ id: 'custom:codeatlasweb-twin',
+ label: messages.twin.profileLabel,
+ depth: 2,
+ facets: [Facet.Logic, Facet.Calls, Facet.Data, Facet.Errors, Facet.Tests],
+ terminology: 'technical',
+ conceptCallouts: false,
+ panels: [],
+ aiChangeWatcher: false,
+};
+
+/**
+ * Die Adressparameter, mit denen die Grenzen des Vorwaerts-Walks von aussen
+ * gesetzt werden koennen.
+ *
+ * Dieselben zwei Namen wie im Referenzprojekt (pseudocode-service.ts), und sie
+ * sind kein Debug-Schalter: die Karte sagt am Ende eines gekappten Walks, dass
+ * sie gekappt wurde, und dieser Satz muss an einem echten Lauf zu sehen sein,
+ * nicht nur in einem Unit-Test. An einem Projekt mit zehn Dateien kappt der
+ * Vorgabe-Deckel von fuenfzehn Symbolen nichts; mit `?codeatlasClosureCap=3`
+ * kappt er, und der Beweislauf liest den Satz dort, wo ein Leser ihn saehe.
+ */
+export const CLOSURE_DEPTH_PARAM = 'codeatlasClosureDepth';
+export const CLOSURE_CAP_PARAM = 'codeatlasClosureCap';
+
+/*
+ * Die sichtbaren Saetze dieser Datei stehen seit W6a im Katalog
+ * (src/i18n/messages.ts) und werden hier nur noch unter ihren bisherigen Namen
+ * weitergereicht. Die Namen bleiben, weil sie exportiert sind und anderswo
+ * gelesen werden; der Wortlaut hat jetzt genau eine Quelle.
+ */
+
+/** Was die Statusleiste sagt, wenn eine Fuehrung nicht zustande kam. */
+export const TOUR_FAILED = messages.tour.failed;
+
+/** Was in der Kommandozeile steht, wenn nichts gesucht wird. */
+export const COMMAND_HINT_IDLE = messages.command.hintIdle;
+/** Was dort steht, waehrend das Fenster offen ist. */
+export const COMMAND_HINT_OPEN = messages.command.hintOpen;
+/** Was das Trefferfenster sagt, solange erst ein Zeichen getippt ist. */
+export const COMMAND_ONE_MORE_LETTER = messages.search.oneMoreLetter;
+/** Was das Fenster sagt, wenn die Suche selbst gescheitert ist. */
+export const SEARCH_FAILED = messages.search.failed;
+/** Was es sagt, wenn ein gewaehlter Treffer keine Datei traegt. */
+export const SEARCH_NO_FILE = messages.search.noFile;
+
+const emptyLevels = new Map();
+const noBadges: ReturnType = [];
+const noHits: RankedHit[] = [];
+/** Das Trefferfenster ohne Treffer, als stabile Referenz. */
+const noRows: SearchRow[] = [];
+
+/**
+ * Die beiden Griffe, an denen der Beweislauf die Fuehrung und die stille
+ * Anzeige anfasst.
+ *
+ * Absichtlich schmal, wie die Griffe des Readers, des Twins und der Galaxie:
+ * alles davon steht auch im DOM, der Griff spart dem Lauf das Parsen und macht
+ * zwei Dinge ueberhaupt messbar, naemlich "dasselbe Dokument zweimal" und "die
+ * Zahl der Vermerke ist gestiegen".
+ */
+export interface AtlasTourSeam {
+ id: string;
+ kind: 'project' | 'entry';
+ title: string;
+ steps: number;
+ index: number;
+ paths: string[];
+ titles: string[];
+ endNote: string;
+ /** Das Dokument, wie es gerade gelaufen wird. */
+ json: string;
+ /** Noch einmal erzeugen und serialisieren. Nur fuer die Projekt-Fuehrung. */
+ regenerate?: () => Promise;
}
-/* Read the active tab + selected project from the URL query string so the
- * current view survives refreshes and can be bookmarked or shared. */
-function readRoute(): RouteState {
- const params = new URLSearchParams(window.location.search);
- const rawTab = params.get("tab");
- const tab = TAB_IDS.includes(rawTab as TabId) ? (rawTab as TabId) : "stats";
- const project = params.get("project");
- return { tab, project: project ? project : null };
+/**
+ * Der Griff, an dem der Beweislauf den Coverage-Join anfasst.
+ *
+ * Er traegt genau das, was auch im DOM steht, und spart dem Lauf das Parsen.
+ * Die Zeilen kommen mit ihrer Stufe, weil "der Baum zeigt exakt das Gemessene"
+ * sonst nur ueber Screenshot-Vergleiche pruefbar waere, und ein Bild ist keine
+ * Behauptung ueber Pfade.
+ */
+export interface AtlasCoverageSeam {
+ rows: { path: string; kind: string; coverage: string; reason: string; sources: string[] }[];
+ /** Je Pfad genau ein Befund, so wie der Join ihn haelt. */
+ records: { path: string; kind: string; state: string; reason: string; sources: string[] }[];
+ truncations: string[];
+ counts: CoverageIndex['counts'];
+ /** Die Metadaten des Coverage-Stores, inklusive der ignored_files-Zahlen. */
+ metadata: Record;
+ /** Was ueber die offene Datei gerade bekannt ist. */
+ open: { path: string; status: string; freshness: string; action: string; note: string } | undefined;
+ /** Leer, solange nichts schiefging. */
+ error: string;
}
-/* Build the canonical URL for a route, preserving the path and hash. */
-function routeUrl(tab: TabId, project: string | null): string {
- const params = new URLSearchParams();
- params.set("tab", tab);
- if (project) params.set("project", project);
- return `${window.location.pathname}?${params.toString()}${window.location.hash}`;
+export interface AtlasChecklistSeam {
+ /** Das Symbol, ueber das die Statusleiste gerade zaehlt. Leer, wenn keines. */
+ symbol: string;
+ explored: number;
+ total: number;
+ /** Genau das, was in der Statusleiste steht. Leer heisst: kein Zaehler. */
+ label: string;
+ /** Vermerke dieses Projekts insgesamt. Die eine Zahl, die nur steigt. */
+ marks: number;
}
-export function App() {
- const t = useUiMessages();
- const [route, setRoute] = useState(readRoute);
- const [version, setVersion] = useState(null);
- const { tab: activeTab, project: selectedProject } = route;
+/**
+ * Der Griff, an dem der Beweislauf das lokale Modell anfasst.
+ *
+ * Er traegt zwei Dinge, die im DOM nicht stehen und ohne die der Lauf raten
+ * muesste: das Urteil der Policy samt Begruendung, und die Zahl der Proben, die
+ * ueberhaupt losgeschickt wurden. Die zweite ist der Gegenbeweis zur ersten
+ * Behauptung dieses Zyklus: solange das LLM aus ist, bleibt sie null, und der
+ * Netz-Mitschnitt sagt dasselbe aus der anderen Richtung.
+ */
+/**
+ * Der Anfangswert der Probe: nichts gefragt, nichts gefunden.
+ *
+ * Als Konstante und nicht als Literal an drei Stellen: er wird beim ersten Bild
+ * gesetzt, beim Projektwechsel und beim Ausschalten, und drei Fassungen davon
+ * waeren drei Gelegenheiten, eine zu vergessen.
+ */
+const EMPTY_SIDECAR_READING: SidecarReading = {
+ state: 'not-running',
+ router: false,
+ models: [],
+ detail: '',
+};
- useEffect(() => {
- let cancelled = false;
- void fetch("/api/ui-config")
- .then((response) => (response.ok ? response.json() : null))
- .then((config) => {
- if (!cancelled && typeof config?.version === "string" && config.version) {
- setVersion(config.version);
- }
- })
- .catch(() => {});
- return () => {
- cancelled = true;
+/** Eine leere Liste mit fester Identitaet, damit sie keine Effekte anstoesst. */
+const NO_CACHE_MODELS: readonly CacheModel[] = [];
+
+export interface AtlasLlmSeam {
+ state: SidecarState;
+ /** Ob dieser Browser fuer dieses Projekt eingeschaltet hat. */
+ preferenceOn: boolean;
+ /** Das Urteil der committeten Policy. Leer, solange noch nicht gelesen. */
+ policyVerdict: string;
+ policyPath: string;
+ policyDetail: string;
+ /** Der Modellname, so wie er im Panel steht. Leer, wenn keiner bekannt ist. */
+ model: string;
+ /** Was in der Statusleiste steht. */
+ chip: string;
+ /** Wie oft ueberhaupt zum Sidecar gefragt wurde, seit die Seite geladen ist. */
+ probes: number;
+}
+
+/**
+ * Der Griff, an dem der Beweislauf den Chat und die Umformulierung anfasst.
+ *
+ * Zwei Dinge stehen hier, die im DOM nicht stehen und ohne die ein Lauf raten
+ * muesste: die Kartennummern, die eine Antwort wirklich zitiert hat (im Text
+ * stehen sie als Knoepfe, aber nicht die Liste der gegebenen Karten daneben),
+ * und `validateRefine`. Das zweite ist kein Nachbau: es ruft denselben
+ * Validator, den der Knopf ruft, mit einer absichtlich kaputten Antwort. Ein
+ * Lauf, der die Regel nachbaut, prueft seine Kopie und nicht das Produkt.
+ */
+export interface AtlasChatSeam {
+ turns: {
+ id: number;
+ question: string;
+ status: string;
+ klass: string;
+ rule: string;
+ depth: number;
+ cards: number;
+ cardIds: string[];
+ sources: number;
+ citations: string[];
+ unknownCitations: string[];
+ uncitedLines: number;
+ answer: string;
+ refusal: string;
+ tokens: number;
+ budget: number;
+ tokensPerSecond: number;
+ /** Die Kandidaten, wenn dieser Zug auf eine Wahl wartet. */
+ candidates: { name: string; qualifiedName: string; filePath: string; line: number }[];
+ /** Der Name, der mehrdeutig war oder nicht gefunden wurde. Leer, wenn keiner. */
+ askedName: string;
+ /** Das Fokus-Symbol, das statt eines nicht gefundenen Namens geantwortet hat. */
+ focusFallbackUsed: string;
+ }[];
+ /**
+ * Ob der Chat gerade sichtbar ist.
+ *
+ * Seit W8 heisst das: der Erklaeren-Bereich ist offen UND sein Reiter ist
+ * `chat`. Eingeklappt oder auf einem anderen Reiter bleiben die Zuege
+ * trotzdem stehen, und genau das ist die Zusicherung, die W7c erkaempft hat
+ * und die hier weiter gemessen wird.
+ */
+ open: boolean;
+ /** Die Hoehe, die der Chat gerade hat. Seit W8 ist es die Hoehe seiner Zone. */
+ height: number;
+ /** Der Deckel, den dieses Fenster gerade zulaesst. Ebenfalls der der Zone. */
+ maxHeight: number;
+ depth: number;
+ depthDefault: number;
+ depthOptions: number[];
+ refineAvailable: boolean;
+ refineState: string;
+ refineMessage: string;
+ validateRefine: (answer: string) => { applied: boolean; reason: string };
+}
+
+/**
+ * Der Griff, an dem der Beweislauf die Suche und die Menuezeile anfasst.
+ *
+ * Er traegt genau das, was im DOM nicht steht und was ein Lauf sonst aus
+ * Wartezeiten raten muesste: wie lange der Serverweg gedauert hat, wie oft ein
+ * Verlaengern des Wortes aus dem Praefix-Cache beantwortet wurde, wie oft eine
+ * ueberholte Runde abgebrochen wurde, und ob je eine ueberholte Antwort die
+ * neuere ueberschrieben hat. Die letzte Zahl ist die einzige, die falsch sein
+ * darf: sie ist der Gegenbeweis zur Zusicherung dieses Zyklus und muss null
+ * bleiben.
+ *
+ * `activatedMenus` ist die Liste der Menuepunkte, die wirklich GELAUFEN sind,
+ * je Buchstabe. Sie wird an der Stelle geschrieben, an der die Handlung
+ * ausgefuehrt wird, und nicht dort, wo geklickt wurde: ein Lauf, der einen
+ * Klick zaehlt, hat den Klick bewiesen und nicht die Handlung dahinter.
+ */
+export interface AtlasSearchSeam {
+ /** Die Entprellung, wie sie wirklich gilt. */
+ debounceMs: number;
+ minQuery: number;
+ /** Was gerade in der Zeile steht. */
+ currentQuery: string;
+ /** Die Anfrage, die die gezeigten Zeilen beantworten. */
+ shownQuery: string;
+ /** Woher die gezeigten Zeilen kommen. */
+ shownSource: SearchRowSource;
+ shownRows: number;
+ /** Wie oft vorlaeufige Zeilen VOR einer Antwort des Index dastanden. */
+ localFirst: number;
+ /** Wie oft ein verlaengertes Wort aus dem Praefix-Cache beantwortet wurde. */
+ prefixCacheHits: number;
+ /** Runden, die wirklich zum Server gingen. */
+ serverRequests: number;
+ /** Der letzte gemessene Serverweg, in Millisekunden. */
+ serverRoundtripMs: number;
+ /** Jeder gemessene Serverweg dieser Sitzung. */
+ roundtrips: number[];
+ /** Runden, die abgebrochen wurden, weil ein Tastendruck sie ueberholt hat. */
+ abortedRequests: number;
+ /** Antworten, die zu spaet kamen und deshalb verworfen wurden. */
+ staleDropped: number;
+ /** Muss false bleiben: eine ueberholte Antwort hat die neuere ueberschrieben. */
+ staleAnswerWins: boolean;
+ /** Kandidaten, die ohne Serverweg zur Verfuegung stehen. */
+ localCandidates: number;
+ /** Die Buchstaben der Menuepunkte, deren Handlung gelaufen ist, in ihrer Reihenfolge. */
+ activatedMenus: string[];
+ /** Ob der Griff am Fenster in der einfangenden Phase haengt. */
+ keyListenerCapture: boolean;
+}
+
+/**
+ * Der Griff, an dem der Beweislauf das Layout anfasst.
+ *
+ * Er traegt drei Dinge, die im DOM nicht oder nur als Pixel stehen, und jedes
+ * davon ist eine Frage, die ein Lauf sonst raten muesste:
+ *
+ * - **Die Zahlen mit ihren Grenzen.** Ein Lauf kann ein Rechteck messen; ob
+ * das gemessene Rechteck am Anschlag steht oder nur zufaellig so gross ist,
+ * sagt ihm erst `bounds`. Genau daran haengt AC2 ("keine Zone darf
+ * wegziehbar sein"): gemessen wird gegen die Grenze, die die Oberflaeche
+ * selbst nennt, und nicht gegen eine im Lauf abgeschriebene Zahl.
+ * - **Der Zustand hinter den Reitern.** AC5 verlangt, dass ein Reiterwechsel
+ * keinen Zustand kostet. Der Chat-Verlauf steht im DOM, der Walk-Schritt und
+ * die Flow-Stelle stehen dort nur, solange ihr Reiter offen ist. Hier stehen
+ * alle drei jederzeit, und deshalb kann ein Lauf sie VOR und NACH einem
+ * Wechsel vergleichen, statt zu behaupten, sie seien noch da.
+ * - **Der Schluessel im Speicher.** Damit ein Lauf pruefen kann, dass die
+ * Masse pro Projekt gefuehrt werden, ohne den Schluessel nachzubauen.
+ */
+export interface AtlasLayoutSeam {
+ /** Die Masse, die WIRKLICH gezeichnet werden. Gegen sie misst der Lauf Rechtecke. */
+ sizes: LayoutSizes;
+ /**
+ * Die Masse, die der Leser gezogen hat.
+ *
+ * Sie koennen groesser sein als `sizes`: ein kleines Fenster begrenzt die
+ * Darstellung und nicht den Wunsch. Ohne diese zweite Zahl saehe ein Lauf
+ * nur, dass eine Spalte schmal ist, und nicht, ob sie es bleiben wird.
+ */
+ requested: LayoutSizes;
+ bounds: Record;
+ defaults: LayoutSizes;
+ /** Ob alle vier Zahlen auf ihrer Vorgabe stehen. */
+ isDefault: boolean;
+ frame: LayoutFrame;
+ explainOpen: boolean;
+ explainTab: string;
+ tabs: { id: string; enabled: boolean; reason: string; note: string }[];
+ storageKey: string;
+ /** Was hinter den Reitern liegt, unabhaengig davon, welcher offen ist. */
+ state: {
+ chatTurns: number;
+ walkStep: number;
+ walkSteps: number;
+ flowStep: number;
+ flowSteps: number;
};
- }, []);
-
- /* Normalize the URL on first load so it always carries the current route. */
- useEffect(() => {
- const initial = readRoute();
- window.history.replaceState(null, "", routeUrl(initial.tab, initial.project));
- }, []);
-
- /* Sync state when the user navigates with the browser back/forward buttons. */
- useEffect(() => {
- const onPopState = () => setRoute(readRoute());
- window.addEventListener("popstate", onPopState);
- return () => window.removeEventListener("popstate", onPopState);
- }, []);
-
- /* Change the route and push a history entry (skips no-op navigations). */
- const navigate = useCallback((tab: TabId, project: string | null) => {
- const url = routeUrl(tab, project);
- const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
- if (url === current) return;
- window.history.pushState(null, "", url);
- setRoute({ tab, project });
- }, []);
-
- const tabs: { id: TabId; label: string }[] = [
- { id: "graph", label: t.tabs.graph },
- { id: "stats", label: t.tabs.projects },
- { id: "control", label: t.tabs.control },
- ];
-
- return (
-
- {/* Header */}
-
-
-
-
-
- Codebase Memory
-
- {version && (
-
- {version.startsWith("v") ? version : `v${version}`}
-
+}
+
+/**
+ * Der Griff, an dem der Beweislauf das Einstellungen-Panel anfasst (W10).
+ *
+ * Er traegt drei Dinge, die im DOM nicht oder nur als Text stehen, und jedes
+ * davon ist eine Frage, die ein Lauf sonst raten muesste:
+ *
+ * - **Die Wahl und ihr Speicherort.** Ob eine Wahl den Reload ueberlebt, kann
+ * ein Lauf nur pruefen, wenn er den Schluessel kennt, ohne ihn nachzubauen.
+ * - **Was der Prozess wirklich gemeldet hat.** Die Liste der Cache-Modelle
+ * steht auch im DOM, aber ohne die Angabe, ob der Prozess ein Router ist,
+ * und genau daran haengt, ob eine Auswahl ueberhaupt wirkt.
+ * - **Die letzten Messungen.** Sie stehen als Saetze auf dem Schirm; hier
+ * stehen sie als Zahlen, mit dem Rauschband daneben, damit ein Lauf das
+ * Urteil nachrechnen kann, statt einen Satz zu lesen.
+ */
+export interface AtlasSettingsSeam {
+ open: boolean;
+ /** Ob das lokale Modell an ist. Ist es aus, hat dieses Panel nichts gefragt. */
+ llmOn: boolean;
+ /** Wie oft ueberhaupt zum Sidecar gefragt wurde. Dieselbe Zahl wie in __atlasLlm. */
+ probes: number;
+ /** Ob der Prozess als Router ueber ein Cache-Verzeichnis laeuft. */
+ router: boolean;
+ /** Die id, an die die naechste Frage geht. Leer heisst: keine Wahl getroffen. */
+ selectedModel: string;
+ /** Was `/v1/models` gelistet hat, mit der aktiven Zeile markiert. */
+ cacheModels: { id: string; name: string; loaded: boolean; status: string; active: boolean }[];
+ /** Die vier Zahlen des laufenden Modells, so wie das Panel sie zeigt. */
+ running: {
+ model: string;
+ modelPath: string;
+ quantization: string;
+ contextTokens: number;
+ trainedContextTokens: number;
+ weightsBytes: number;
+ parameters: number;
+ };
+ /** Die kuratierten Vorschlaege mit ihren gemessenen Zahlen und ihrem Befehl. */
+ suggestions: {
+ id: string;
+ name: string;
+ repo: string;
+ modelClass: string;
+ passRate: number;
+ citationCompliance: number;
+ /** `null` heisst: der aufgezeichnete Lauf weist die Zahl nicht aus. */
+ citationUnmeasured: number | null;
+ tokensPerSecond: number;
+ bytes: number;
+ command: string;
+ }[];
+ /** Die Anzeige-Einstellungen, ihre Vorgabe und ob beides dasselbe ist. */
+ display: GraphDisplaySettings;
+ displayDefault: GraphDisplaySettings;
+ isDefaultDisplay: boolean;
+ /** Die zwei Schluessel im Speicher dieses Browsers. */
+ storageKeys: { model: string; display: string };
+ /** Die letzten abgeschlossenen Messungen, eine je Einstellung. */
+ measurements: SettingsMeasurement[];
+}
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __atlasSettings: AtlasSettingsSeam | undefined;
+ // eslint-disable-next-line no-var
+ var __atlasLayout: AtlasLayoutSeam | undefined;
+ // eslint-disable-next-line no-var
+ var __atlasSearch: AtlasSearchSeam | undefined;
+ // eslint-disable-next-line no-var
+ var __atlasChat: AtlasChatSeam | undefined;
+ // eslint-disable-next-line no-var
+ var __atlasTour: AtlasTourSeam | undefined;
+ // eslint-disable-next-line no-var
+ var __atlasChecklist: AtlasChecklistSeam | undefined;
+ // eslint-disable-next-line no-var
+ var __atlasCoverage: AtlasCoverageSeam | undefined;
+ // eslint-disable-next-line no-var
+ var __atlasLlm: AtlasLlmSeam | undefined;
+}
+
+/** Was ein Projektfenster dem naechsten mitgibt (K24): den Arbeitsbereich, die Kopfzeile des Agenten und ob der Chat offen ist. */
+interface WindowCarry {
+ workspace: Workspace;
+ chatOpen: boolean;
+ agentState: 'off' | 'loading' | 'active' | 'busy' | 'error';
+ agentModelName: string;
+ agentHasOpened: boolean;
+}
+
+/**
+ * Jedes Projekt bekommt ein frisches Fenster in derselben Seite (K24, Kopf von
+ * src/app/project-windows.tsx). Der Agent reicht beim Wechsel sein geladenes
+ * Modell an das Fenster des neuen Projekts weiter. Die Adresse behaelt dabei
+ * den Arbeitsbereich und die Grenzen des Walks, nichts vom alten Projekt (A5).
+ */
+const PAGE_PARAMS = ['workspace', CLOSURE_DEPTH_PARAM, CLOSURE_CAP_PARAM] as const;
+export default function App(): JSX.Element {
+ return around={switchKeepingAgent} keepParams={PAGE_PARAMS}>
+ {shown => }
+ ;
+}
+
+function ProjectWindowView({ projectWindow }: { projectWindow: ProjectWindow }): JSX.Element {
+ const carried = projectWindow.carried;
+ const [workspace, setWorkspace] = useState(() => {
+ try {
+ // A project switch keeps the workspace in view; Back and Forward restore the one of their entry (K24).
+ const requested = new URLSearchParams(window.location.search).get('workspace') ?? carried?.workspace ?? null;
+ let saved: string | null = null;
+ try { saved = localStorage.getItem('cbm.workspace'); } catch { /* URL navigation works without storage. */ }
+ return initialWorkspace(requested, saved);
+ } catch { return 'architecture'; }
+ });
+ const changeWorkspace = (value: Workspace): void => {
+ value = allowedWorkspace(value);
+ if (value !== workspace) {
+ setProactiveSelection(undefined); setChatGraphSelection(undefined); setChatAttachment(undefined);
+ }
+ setWorkspace(value);
+ try { localStorage.setItem('cbm.workspace', value); } catch { /* Session-only preference. */ }
+ };
+ // The address always names the workspace in view, also one carried over a project switch, so a reload keeps it (A5).
+ useParamInAddress('workspace', workspace);
+ const [welcomeOpen, setWelcomeOpen] = useState(() => {
+ try { return localStorage.getItem('cbm.workspace.setup') !== 'done'; } catch { return false; }
+ });
+ // The agent's lamp and the chat stay as they were across a project switch (K24).
+ const [browserAiOpen, setBrowserAiOpen] = useState(carried?.chatOpen ?? false);
+ const [localAgentState, setLocalAgentState] = useState<'off' | 'loading' | 'active' | 'busy' | 'error'>(carried?.agentState ?? 'off');
+ const [agentSettingsRequest, setAgentSettingsRequest] = useState(0);
+ const [agentModelName, setAgentModelName] = useState(carried?.agentModelName ?? '');
+ const agentHasOpened = useRef(carried?.agentHasOpened ?? false);
+ useEffect(() => {
+ projectWindow.report({ workspace, chatOpen: browserAiOpen, agentState: localAgentState, agentModelName, agentHasOpened: agentHasOpened.current });
+ });
+ const onAgentStateChange = useCallback((state: 'off' | 'loading' | 'active' | 'busy' | 'error') => {
+ setLocalAgentState(state);
+ if ((state === 'active' || state === 'busy') && !agentHasOpened.current) {
+ agentHasOpened.current = true; setBrowserAiOpen(true);
+ }
+ }, []);
+ const openAgentSettings = () => { setAgentSettingsRequest(value => value + 1); };
+ const [codeDetailsOpen, setCodeDetailsOpen] = useState(false);
+ const [readerSelection, setReaderSelection] = useState();
+ const [pinnedCode, setPinnedCode] = useState();
+ const [fileSymbols, setFileSymbols] = useState({ project: '', path: '', symbols: [], message: '', status: 'ready' });
+ const [chatAttachment, setChatAttachment] = useState();
+ const [chatGraphSelection, setChatGraphSelection] = useState();
+ const [proactiveSelection, setProactiveSelection] = useState<{ scope: string; context: BrowserChatContext }>();
+ const [galaxySelection, setGalaxySelection] = useState();
+ const [galaxySelectionProject, setGalaxySelectionProject] = useState('');
+ const finishSetup = (): void => {
+ setWelcomeOpen(false);
+ setWhyDismissed(true);
+ try { localStorage.setItem('cbm.workspace.setup', 'done'); } catch { /* Session-only preference. */ }
+ };
+ const guidance: Guidance = 'brief';
+ // Requests of this window end with it: a switch to another project aborts them (K24).
+ const client = useMemo(() => new RpcIntelligenceClient({ signal: projectWindow.signal }), [projectWindow.signal]);
+ const api = useMemo(() => new AtlasApi({ signal: projectWindow.signal }), [projectWindow.signal]);
+ const [diagnosticsPath, setDiagnosticsPath] = useState();
+
+ /*
+ * What the projects panel asks the server. The list comes over /rpc like
+ * every other read of this window; the rest are the /api routes the panel
+ * names in its own text. Opening a project replaces this window with a
+ * fresh one for that project in the same page, because every panel of this
+ * window is keyed to the project it started with (K24).
+ */
+ const projectsSource = useMemo(
+ () => ({
+ listProjects: () => client.listProjects().then((result) => result.projects),
+ projectHealth: (name) => api.projectHealth(name),
+ deleteProject: (name) => api.deleteProject(name),
+ browse: (path) => api.browse(path),
+ startIndex: (rootPath, projectName) => api.startIndex(rootPath, projectName),
+ indexJobs: () => api.indexJobs(),
+ adr: (name) => api.adr(name),
+ saveAdr: (name, content) => api.saveAdr(name, content),
+ logs: (lines) => api.logs(lines),
+ processes: () => api.processes(),
+ uiLogTail: (lines) => api.uiLogTail(lines),
+ }),
+ [client, api],
+ );
+ // Opening a project stays in this page and keeps a loaded agent (K24).
+ const openProject = projectWindow.open;
+
+ const [project, setProject] = useState('');
+ const selectionScope = `${project}:${workspace}`;
+ const onSelectionEvidence = useCallback((context: BrowserChatContext | undefined) => {
+ setProactiveSelection(previous => context
+ ? previous?.scope === selectionScope && previous.context.text === context.text ? previous : { scope: selectionScope, context }
+ : undefined);
+ }, [selectionScope]);
+ useEffect(() => { setProactiveSelection(undefined); setChatGraphSelection(undefined); setChatAttachment(undefined); }, [project]);
+ const [projectDetail, setProjectDetail] = useState('resolving project ...');
+ const [serverOk, setServerOk] = useState(undefined);
+ const [counts, setCounts] = useState<{ nodes?: number; edges?: number }>({});
+
+ const [levels, setLevels] = useState>(emptyLevels);
+ const [expanded, setExpanded] = useState>(new Set());
+ const [budgetHit, setBudgetHit] = useState(false);
+ const [treeError, setTreeError] = useState('');
+ const [cursor, setCursor] = useState(0);
+
+ /*
+ * Der Coverage-Befund: die zweite Haelfte der Baumquelle.
+ *
+ * `EMPTY_COVERAGE` heisst "noch nicht gefragt" und nicht "keine Luecken";
+ * der Unterschied steht in der Notiz unter dem Baum. Ein gescheiterter Lauf
+ * wird darum ausdruecklich gemeldet, statt einen leeren Befund zu setzen.
+ */
+ const [coverage, setCoverage] = useState(EMPTY_COVERAGE);
+ const [coverageMeta, setCoverageMeta] = useState>({});
+ const [coverageAsked, setCoverageAsked] = useState(false);
+ const [coverageError, setCoverageError] = useState('');
+ /** Was der Store ueber die offene Datei sagt, mit Frische. */
+ const [openCoverage, setOpenCoverage] = useState(undefined);
+
+ const [tabs, setTabs] = useState([]);
+ const [activePath, setActivePath] = useState('');
+ const [document, setDocument] = useState(undefined);
+ const [documentOrigin, setDocumentOrigin] = useState();
+ const [readerStatus, setReaderStatus] = useState('idle');
+ const [readerMessage, setReaderMessage] = useState('pick a file in the explorer');
+ const [command, setCommand] = useState('');
+
+ useEffect(() => { setPinnedCode(undefined); }, [project]);
+ useEffect(() => {
+ if (!project || !activePath) return;
+ let cancelled = false;
+ setFileSymbols({ project, path: activePath, symbols: [], message: '', status: 'loading' });
+ void loadFileSymbols(client, project, activePath).then(result => {
+ if (!cancelled) setFileSymbols({ ...result, project, path: activePath, status: 'ready' });
+ }).catch((error: unknown) => {
+ if (!cancelled) setFileSymbols({ project, path: activePath, symbols: [], status: 'error',
+ message: error instanceof Error ? error.message : String(error) });
+ });
+ return () => { cancelled = true; };
+ }, [client, project, activePath]);
+
+ // Zaehler gegen Wettlaeufe: klickt jemand schnell durch zwei Dateien, darf
+ // die langsamere Antwort die schnellere nicht ueberschreiben.
+ const loadTicket = useRef(0);
+
+ // Der Coverage-Befund als Ref, damit `openFile` ihn lesen kann, ohne bei
+ // jeder neuen Antwort seine Identitaet zu wechseln: an `openFile` haengen
+ // die Fuehrungen, der Twin und die Galaxie, und ein Wechsel dort haengt
+ // eine Handvoll Effekte neu auf.
+ const coverageRef = useRef(EMPTY_COVERAGE);
+ coverageRef.current = coverage;
+ const coverageTicket = useRef(0);
+
+ // ------------------------------------------------------- Twin ----------
+
+ const provider = useMemo(() => new CbmRpcProvider(client, { generation: 1 }), [client]);
+ // Das Profil ist seit W4a beweglich: die Antwort auf "warum bist du hier"
+ // setzt es. Die Regler des Lesers liegen als Overlay darauf, so wie vorher,
+ // und werden beim Profilwechsel geleert: eine abgeschaltete Linse, die ueber
+ // ein neues Profil hinweg abgeschaltet bliebe, waere eine Sektion, die
+ // fehlt, ohne dass jemand sagt warum.
+
+ const [twinSymbol, setTwinSymbol] = useState(undefined);
+ const [twinIr, setTwinIr] = useState(undefined);
+ const [twinStatus, setTwinStatus] = useState('empty');
+ const [twinMessage, setTwinMessage] = useState(TWIN_EMPTY_MESSAGE);
+ const [twinHint, setTwinHint] = useState(TWIN_EMPTY_HINT);
+ const [twinName, setTwinName] = useState('no symbol');
+ const [caretLine, setCaretLine] = useState(undefined);
+ const [settledCaret, setSettledCaret] = useState(undefined);
+ const [pointedLine, setPointedLine] = useState(undefined);
+ const [reveal, setReveal] = useState<{ line: number; nonce: number } | undefined>(undefined);
+
+ /*
+ * Der Erklaeren-Bereich: welcher Reiter gilt, und ob der Bereich offen ist.
+ *
+ * Zwei Angaben und nicht fuenf Schalter, und das ist der ganze Umbau von W8.
+ * Bis dahin hatte jede der fuenf Flaechen ihren eigenen "offen"-Zustand, und
+ * genau daraus entstand der Nutzerbefund vom 2026-08-29: drei davon standen
+ * gleichzeitig auf `true`, lagen uebereinander und waren alle drei
+ * angeschnitten. Mit EINEM Reiter kann das nicht mehr vorkommen; es ist
+ * keine Regel, die jemand einhalten muss, sondern eine Form, in der der
+ * Fehler nicht formulierbar ist.
+ *
+ * Eingeklappt per Vorgabe (AC4): eine Flaeche, die beim Oeffnen eines
+ * Projekts von selbst aufgeht, erklaert etwas, wonach niemand gefragt hat.
+ */
+ const [explainOpen, setExplainOpen] = useState(false);
+ const [explainTab, setExplainTab] = useState('flow');
+ /*
+ * Der aktive Reiter, auch fuer die Rueckrufe, die ihn nicht als Abhaengigkeit
+ * haben duerfen. Derselbe Umweg wie bei `menuActionsRef` und aus demselben
+ * Grund: ein Griff, der bei jedem Reiterwechsel ab- und wieder angemeldet
+ * wird, ist ein Griff, der zwischendurch taub ist.
+ */
+ const explainTabRef = useRef('flow');
+ explainTabRef.current = explainTab;
+ /*
+ * Die Reiter, wie sie das letzte Bild gezeichnet hat, fuer den Griff des
+ * Beweislaufs. Sie entstehen unten aus Tatsachen; hier steht nur die
+ * Ablage, damit der Effekt, der den Griff schreibt, sie sehen kann.
+ */
+ const explainTabsRef = useRef([]);
+ /*
+ * Ob der Flow gerade zu sehen ist.
+ *
+ * Abgeleitet und nicht gespeichert: ein zweiter Schalter neben dem Reiter
+ * waere die Stelle, an der beide auseinanderlaufen. Der Schritt selbst steht
+ * auf -1, weil "noch keiner" kein Schritt ist; eine 0 hier waere der erste
+ * Schritt und wuerde beim Oeffnen ungefragt den Editor bewegen.
+ */
+ const flowOpen = explainOpen && explainTab === 'flow';
+ /*
+ * Die Hilfeseite (W7a).
+ *
+ * Zu per Vorgabe, aus demselben Grund wie der Erklaerer: eine Flaeche ueber
+ * dem Editor geht nicht ungefragt auf. Ein Zustand und kein Router-Pfad,
+ * weil diese Oberflaeche keine Adressen hat, hinter denen Seiten liegen; sie
+ * hat Flaechen, die auf- und zugehen.
+ */
+ const [helpOpen, setHelpOpen] = useState(false);
+ /*
+ * Das Einstellungen-Panel (W10), aus denselben Gruenden zu per Vorgabe wie
+ * die Hilfe: eine Flaeche ueber dem Editor geht nicht ungefragt auf.
+ */
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [projectsOpen, setProjectsOpen] = useState(false);
+ const [indexActivity, setIndexActivity] = useState<{ name: string; status: 'indexing' | 'done' | 'error' }>();
+ /*
+ * Die Anzeige-Einstellungen des Lesers.
+ *
+ * Sie stehen HIER und nicht im Panel, weil zwei Flaechen sie brauchen: das
+ * Panel stellt sie ein, das Graph-Panel zeichnet danach. Ein Zustand im
+ * Einstellungen-Panel waere weg, sobald jemand es zuklappt, und genau das
+ * darf eine Einstellung nicht sein.
+ */
+ const [display, setDisplay] = useState(DEFAULT_GRAPH_DISPLAY);
+ /*
+ * Der Live-Modus der Agenten (W11a).
+ *
+ * AUS per Vorgabe, und er wird auch nicht gespeichert. Dieselbe Regel wie
+ * beim lokalen Modell: solange er aus ist, geht keine einzige Agentenabfrage an den
+ * Daemon, und eine Seite, die nach dem naechsten Laden von selbst wieder zu
+ * reden anfaengt, waere ein Schalter, ueber den der Leser nur einmal
+ * entschieden hat. Er steht hier und nicht im Graph-Panel, weil ihn zwei
+ * Wege umlegen: der Menuepunkt und die Kommandozeile.
+ */
+ const [liveAgentsOn, setLiveAgentsOn] = useState(false);
+ /*
+ * Die Bitte um das Vollbild, als Zaehler (W11b).
+ *
+ * Dieselbe Form wie `graphRefit`: die Wahl selbst liegt im Panel, weil sie
+ * dort gespeichert wird; was von hier kommt, ist die Bitte. Ein zweiter Ort
+ * fuer denselben Zustand waere zwei Wahrheiten darueber, ob das Bild gerade
+ * das ganze Fenster fuellt.
+ */
+ const [fullscreenToggle, setFullscreenToggle] = useState(0);
+ // Compatibility prop for existing HUD consumers; the stream uses this daemon origin.
+ const bridgePort = useMemo(
+ () => bridgePortFromSearch(typeof location === 'undefined' ? '' : location.search),
+ [],
+ );
+ const agentStream = useAgentStream({ on: experimentalAgentsEnabled && liveAgentsOn, project });
+ const agents = useMemo(
+ () => ({ ...agentStream, port: bridgePort, on: experimentalAgentsEnabled && liveAgentsOn }),
+ [agentStream, bridgePort, liveAgentsOn],
+ );
+ /** Die letzten Messungen des Panels, je Einstellung. Nur fuer die Naht. */
+ const [measurements, setMeasurements] = useState>({});
+ const [flowView, setFlowView] = useState(undefined);
+ const [flowStep, setFlowStep] = useState(-1);
+ const [flowMessage, setFlowMessage] = useState(FLOW_LOADING);
+ const [imports, setImports] = useState(undefined);
+ const [pseudocode, setPseudocode] = useState(undefined);
+ const flowTicket = useRef(0);
+ /**
+ * Fuer welches Symbol der Walk schon geholt ist.
+ *
+ * Neu in W8, und der Grund ist AC5: der Flow behaelt seine Stelle ueber einen
+ * Reiterwechsel hinweg. Bis dahin holte der Effekt den Walk jedes Mal neu,
+ * wenn der Erklaerer aufging, und setzte den Schritt dabei auf -1 zurueck.
+ * Als Overlay war das richtig (jedes Aufschlagen war eine neue Sitzung), als
+ * Reiter waere es der Verlust, den dieser Zyklus verbietet. Der Vermerk
+ * unterscheidet die beiden Faelle: dasselbe Symbol wird nicht noch einmal
+ * geholt, ein anderes schon, und der Effekt darueber leert ihn.
+ */
+ const flowLoadedFor = useRef('');
+ const importsTicket = useRef(0);
+ /*
+ * Waehrend einer Stepper-Sitzung folgt der Twin dem Caret nicht.
+ *
+ * Ein Schritt kann in eine andere Datei zeigen (ein erhobener Fehlertyp
+ * steht fast immer im Aufgerufenen), und der Editor folgt ihm. Ohne diese
+ * Klammer wuerde die Aufloesung am Zielort das Subjekt wechseln, der Kasten
+ * waere der eines anderen Symbols, und der Leser haette die Sitzung
+ * verloren, die er gerade fuehrt. Escape beendet sie und gibt den Caret
+ * wieder frei.
+ */
+ /**
+ * Das Subjekt des Twins als Zeichenkette.
+ *
+ * Der Schluessel und nicht das Objekt, und das ist keine Feinheit: die
+ * Aufloesung liefert bei jedem Lauf ein NEUES `SymbolRef`, auch wenn es
+ * dasselbe Symbol meint. Ein Effekt, der an der Objektgleichheit haengt,
+ * wirft dann den geladenen Walk weg, sobald der Caret sich noch einmal
+ * setzt, und der Erklaerer stuende dauerhaft auf "wird geladen". Genau das
+ * ist beim ersten Lauf dieses Zyklus passiert, und der Schluessel ist die
+ * Antwort darauf: gleiches Symbol, gleicher Schluessel, kein Wegwerfen.
+ */
+ const twinKey = twinSymbol === undefined ? '' : (twinSymbol.qualifiedName ?? twinSymbol.name);
+
+ const flowPinned = useRef(false);
+ /*
+ * Der Reiter und nicht die Sichtbarkeit haelt die Klammer.
+ *
+ * Bis W8 stand hier `flowOpen`, also "das Overlay ist zu sehen". Seit der
+ * Erklaerer ein Reiter ist, gibt es einen dritten Zustand: der Reiter ist
+ * gewaehlt, aber die Zone ist eingeklappt (Escape). Die Sitzung ist dann
+ * NICHT beendet, denn Escape kostet seit W8 nichts, und eine Klammer, die
+ * sich beim Einklappen loeste, gaebe den Caret frei und wechselte hinter dem
+ * eingeklappten Bereich das Subjekt. Der Leser klappte wieder auf und faende
+ * das Bild eines anderen Symbols.
+ */
+ flowPinned.current = explainTab === 'flow' && flowStep >= 0;
+
+ /**
+ * Was jeder verdrahtete Menuebuchstabe tut, immer auf dem Stand des Bildes.
+ *
+ * Geschrieben wird es unten bei den Menuepunkten, gelesen oben im
+ * Tastatur-Griff, und der Grund fuer den Umweg ueber ein Ref steht an beiden
+ * Stellen. Der Schluessel ist der Buchstabe aus {@link WIRED_MENU_SHORTCUTS}.
+ */
+ const menuActionsRef = useRef void>>({});
+
+ /**
+ * Was der Tastatur-Griff ueber den Bildschirm wissen muss, ohne ihn neu
+ * aufzuhaengen.
+ *
+ * Zwei Fragen, und beide aendern sich mit jedem Bild: liegt gerade eine
+ * Flaeche ueber dem Editor, und laeuft eine Fuehrung. Sie in die
+ * Abhaengigkeitsliste des Effekts zu schreiben hiesse, den Griff bei jedem
+ * geoeffneten Panel ab- und wieder anzumelden; sie in seine Schliessung zu
+ * schreiben hiesse, dass er die Lage von vor hundert Bildern liest.
+ */
+ const keyboardGuardRef = useRef<{ overlayOpen: boolean; walkRunning: boolean }>({
+ overlayOpen: false,
+ walkRunning: false,
+ });
+
+ /*
+ * Das geladene Dokument als Ref.
+ *
+ * Die Imports-Lesung braucht den Text der Datei, die schon offen ist, und
+ * soll dafuer nicht jedesmal neu aufgehaengt werden, wenn der Reader ein
+ * Zeichen mehr geladen hat. Der Effekt haengt am Pfad und an der IR; der
+ * Text wird zum Zeitpunkt der Frage gelesen.
+ */
+ const documentRef = useRef(undefined);
+ documentRef.current = document;
+
+ const twinTicket = useRef(0);
+ // Das aktuelle Subjekt als Ref, damit die Aufloesung es lesen kann, ohne
+ // dass der Effekt bei jedem Subjektwechsel neu aufgehaengt wird.
+ const shownSymbol = useRef(undefined);
+ shownSymbol.current = twinSymbol;
+ // Die IR ebenfalls als Ref: der Klick auf eine Zeile muss wissen, aus
+ // welcher Checkliste die Zeile stammt, und darf dafuer nicht neu gebunden
+ // werden, sooft das Panel neu zeichnet.
+ const shownIr = useRef(undefined);
+ shownIr.current = twinIr;
+
+ const irCache = useMemo(
+ () =>
+ new IrCache(
+ async (symbol) => buildIr(provider, ATLAS_WORKSPACE_ROOT, symbol, {
+ projectName: project,
+ generation: 1,
+ }),
+ () => {
+ // Nur hier, und nur bei einer echten Anfrage. Der Beweislauf
+ // misst an dieser Zahl, ob ein Caret-Wechsel nachgeladen hat.
+ window.__atlasTwinFetches = (window.__atlasTwinFetches ?? 0) + 1;
+ },
+ ),
+ [provider, project],
+ );
+
+ // ------------------------------------------------- Galaxie und Suche ---
+
+ const [galaxyOn, setGalaxyOn] = useState(true);
+ const [layout, setLayout] = useState(undefined);
+ const [sourceEvidence, setSourceEvidence] = useState<{ project: string; target: SourceEvidenceTarget }>();
+ const [hits, setHits] = useState(noHits);
+ const [selectedHit, setSelectedHit] = useState(0);
+ const [searchStatus, setSearchStatus] = useState('ready');
+ const [searchMessage, setSearchMessage] = useState('');
+ const [answeredQuery, setAnsweredQuery] = useState('');
+ /** Woher die gezeigten Zeilen kommen: aus der Antwort des Index, oder vorlaeufig. */
+ const [hitSource, setHitSource] = useState('index');
+ const searchTicket = useRef(0);
+ /**
+ * Der Abbruch der laufenden Runde.
+ *
+ * Die Ticketpruefung allein reicht seit W7b nicht mehr: sie verhindert, dass
+ * eine ueberholte Antwort die neuere ueberschreibt, laesst die ueberholte
+ * Runde aber zu Ende laufen. Bei einem Wort je Anfrage sind das mehrere
+ * Anfragen, die niemand mehr liest, und sie stehen in der Warteschlange vor
+ * denen, die noch jemand liest. Beides bleibt: der Abbruch spart den Weg,
+ * das Ticket ist die Zusicherung.
+ */
+ const searchAbort = useRef(undefined);
+ /**
+ * Die letzte vollstaendige Antwort des Index, roh.
+ *
+ * Sie traegt das Verlaengern eines Wortes: wer `valid` gefragt hat und dann
+ * `validate` tippt, sieht sofort die neu gerankten Kandidaten der vorigen
+ * Runde, statt auf einen zweiten Weg zum Server zu warten.
+ *
+ * Sie ERSETZT den Weg nicht, und das ist eine bewusste Grenze. Die Antwort
+ * des Servers auf ein laengeres Wort ist nicht garantiert eine Teilmenge
+ * seiner Antwort auf das kuerzere: die BM25-Suche zerlegt Bezeichner in
+ * Woerter, und `validate` kann ein Symbol treffen, das `valid` nicht traf.
+ * Ein Cache, der die Anfrage einspart, wuerde solche Treffer still
+ * verschlucken. Er spart also die WARTEZEIT und nicht die Wahrheit: die
+ * Zeilen stehen sofort da und tragen ihre Marke, und die Antwort des Index
+ * ersetzt sie, sobald sie da ist.
+ */
+ const prefixCache = useRef<
+ { query: string; candidates: SymbolSearchHit[]; complete: boolean } | undefined
+ >(undefined);
+ /** Die Zahlen, die der Beweislauf liest. Siehe AtlasSearchSeam. */
+ const searchStats = useRef({
+ localFirst: 0,
+ prefixCacheHits: 0,
+ serverRequests: 0,
+ serverRoundtripMs: 0,
+ roundtrips: [] as number[],
+ abortedRequests: 0,
+ staleDropped: 0,
+ staleAnswerWins: false,
+ appliedTicket: 0,
+ });
+ /** Die Menuepunkte, deren Handlung gelaufen ist. Siehe AtlasSearchSeam. */
+ const activatedMenus = useRef([]);
+
+ // ------------------------------- Warum, Fuehrungen und die Checkliste ---
+
+ /**
+ * Der Speicher dieses Browsers.
+ *
+ * Einmal geholt und weitergereicht, damit jede Stelle, die etwas merkt oder
+ * nachschlaegt, denselben Speicher benutzt und ein Test ihn ersetzen kann,
+ * ohne globale Objekte zu verbiegen.
+ */
+ const store = useMemo(() => browserStore(), []);
+
+ // ------------------------------------------------ Die Zonen (W8) -------
+
+ /*
+ * Wie gross das Fenster ist, als Zustand und nicht als Abfrage.
+ *
+ * Die Hoechstmasse der vier Zonen haengen daran (src/layout/layout-model.ts),
+ * und ein Griff, dessen `aria-valuemax` aus der Fenstergroesse von vor dem
+ * letzten Ziehen stammt, meldet einen Anschlag, den es nicht gibt. Der
+ * Anfangswert wird beim ersten Bild gelesen und im Effekt darunter sofort
+ * korrigiert: `window.innerHeight` ist beim ersten Bild noch die Hoehe vor
+ * dem Layout. Dieselbe Ueberlegung wie beim Deckel des Antwort-Panels in
+ * W7c, nur fuer alle vier Masse.
+ */
+ const [frame, setFrame] = useState(() => ({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }));
+ const [zones, setZones] = useState(() =>
+ defaultLayout({ width: window.innerWidth, height: window.innerHeight }));
+ const frameRef = useRef(frame);
+ frameRef.current = frame;
+ const zonesRef = useRef(zones);
+ zonesRef.current = zones;
+ const projectRef = useRef('');
+
+ useEffect(() => {
+ const measure = (): void => {
+ setFrame({ width: window.innerWidth, height: window.innerHeight });
+ };
+ measure();
+ window.addEventListener('resize', measure);
+ return () => window.removeEventListener('resize', measure);
+ }, []);
+
+ /**
+ * Die vier Zahlen setzen und merken, an einer Stelle.
+ *
+ * Beides zusammen und nicht in zwei Effekten: ein Effekt, der bei jeder
+ * Aenderung schreibt, schreibt beim Laden zuerst die Vorgabe ueber den
+ * gemerkten Stand und danach den gemerkten Stand zurueck. Das geht gut, bis
+ * jemand das Fenster genau dazwischen schliesst.
+ */
+ const applyZones = useCallback((next: LayoutSizes) => {
+ setZones(next);
+ /*
+ * Gespeichert wird nur, was sich wirklich geaendert hat.
+ *
+ * Ein Zug schickt zwanzig Ereignisse zwischen zwei Bildern, und die
+ * meisten davon tragen dieselbe Zahl (der Griff steht am Anschlag, oder
+ * die Bewegung war kleiner als ein Pixel). Sie alle zu speichern hiesse,
+ * zwanzigmal in localStorage zu schreiben, um nichts zu aendern.
+ *
+ * Der Zustand wird trotzdem IMMER gesetzt, und das ist keine
+ * Unachtsamkeit: `setZones` mit einem neuen Objekt zeichnet ein Bild,
+ * und an diesem Bild haengen die Naehte, an denen der Beweislauf liest.
+ * Ein "reset layout" auf ein Layout, das schon die Vorgabe ist, aendert
+ * nichts und muss trotzdem sichtbar stattgefunden haben; ohne das Bild
+ * meldete der Griff des Laufs, der Menuepunkt habe nicht ausgeloest.
+ * Gemessen an genau diesem Fall (smoke:w7b, `a-layout`).
+ */
+ if (!sameLayout(next, zonesRef.current)) {
+ writeLayout(store, projectRef.current, next);
+ }
+ }, [store]);
+
+ /** Eine Grenze verschieben. Die Grenzen der Grenze kommen aus dem Modell. */
+ const changeZone = useCallback((key: LayoutKey, value: number) => {
+ applyZones({
+ ...zonesRef.current,
+ [key]: clampLayoutValue(key, value, frameRef.current),
+ });
+ }, [applyZones]);
+
+ /** Doppelklick auf einen Griff: genau diese eine Grenze zurueck. */
+ const resetZone = useCallback((key: LayoutKey) => {
+ changeZone(key, defaultLayout(frameRef.current)[key]);
+ }, [changeZone]);
+
+ /**
+ * Die Bitte an das Graph-Panel, sein Bild wieder einzupassen (W10b, AC5).
+ *
+ * Eine Zahl und kein Zustand: jede Erhoehung ist eine neue Bitte. Sie steht
+ * hier, weil "reset layout" sie ausloest; der Knopf in der Szene hat seinen
+ * eigenen Zaehler, und beide gehen im Panel durch dieselbe Rechnung.
+ */
+ const [graphRefit, setGraphRefit] = useState(0);
+
+ /**
+ * Der Weg zurueck aus AC3: alle vier Grenzen auf die Vorgabe.
+ *
+ * Eine Funktion fuer beide Wege (Menuepunkt und Kommandozeile), damit sie
+ * nicht auseinanderlaufen koennen. Der Erklaeren-Bereich klappt dabei NICHT
+ * zu: "reset layout" ist eine Aussage ueber Groessen und nicht darueber, was
+ * der Leser gerade erklaert haben will.
+ *
+ * Seit W10b passt der Graph sein Bild dabei wieder ein. Das ist kein zweiter
+ * Gegenstand, sondern derselbe: die Zonen bekommen ihre Vorgabegroesse
+ * zurueck, und die eingepasste Ansicht IST die Vorgabe des Graphen. Wer sich
+ * in der Wolke verdreht hat, kommt sonst nur ueber den Knopf in der Szene
+ * zurueck, und "reset layout" liesse ausgerechnet die Flaeche stehen, in der
+ * man sich am leichtesten verliert.
+ */
+ const resetLayout = useCallback(() => {
+ applyZones(defaultLayout(frameRef.current));
+ setGraphRefit((count) => count + 1);
+ }, [applyZones]);
+
+ /**
+ * Was gezeichnet wird: der Wunsch des Lesers, so weit das Fenster ihn traegt.
+ *
+ * Zwei Zahlen und nicht eine, und der Unterschied ist ein Nutzerbefund im
+ * Kleinen: `zones` ist, was der Leser GEZOGEN hat, `shownZones` ist, was
+ * davon in dieses Fenster passt. Bis dahin hatte ein Effekt die gezogene
+ * Zahl beim Verkleinern des Fensters einfach ueberschrieben, und beim
+ * Vergroessern kam sie nicht zurueck: wer sein Fenster kurz klein macht,
+ * findet danach schmale Spalten vor und muss alles neu ziehen. Gemessen im
+ * Beweislauf von W5c, der fuer seinen Tab-Test das Fenster verkleinert und
+ * wieder vergroessert: die rechte Spalte blieb bei 402 statt 439 Pixeln
+ * stehen, und der Graph verlor seinen Anteil an der Spalte.
+ *
+ * Der Wunsch bleibt also stehen, und begrenzt wird nur die Darstellung.
+ */
+ const shownZones = useMemo(() => clampLayout(zones, frame), [zones, frame]);
+
+ /*
+ * Die gemerkten Masse dieses Projekts, beim Projektwechsel neu gelesen.
+ *
+ * Pro Projekt, wie der Contract es verlangt: ein Repository mit tiefen
+ * Pfaden braucht einen breiten Explorer, ein flaches nicht, und wer zwischen
+ * beiden wechselt, will nicht jedes Mal nachziehen.
+ */
+ useEffect(() => {
+ projectRef.current = project;
+ setZones(readLayout(store, project, frameRef.current));
+ }, [project, store]);
+
+ const [whyAnswer, setWhyAnswer] = useState({ asked: false });
+ /** Von Hand wieder aufgerufen, ueber das [a]tlas-Menue. */
+ const [whyReopened, setWhyReopened] = useState(false);
+ /**
+ * Ob der Leser die Frage in dieser Sitzung weggelegt hat, indem er anfing zu
+ * lesen.
+ *
+ * Nutzerbefund vom 2026-08-29 mit zwei Screenshots: "wenn ich eine Klasse
+ * anklicke, zum Beispiel orderService.ts, bleibt das und nicht der Code wird
+ * gezeigt." Die Frage verschwand von selbst nur, solange KEINE Datei offen
+ * war; von Hand wieder aufgerufen blieb sie ueber allem stehen, auch ueber
+ * einer Datei. Das war Absicht fuer den Fall "mitten im Lesen den Modus
+ * wechseln", und es ist die falsche Absicht, sobald der Leser danach etwas
+ * anderes tut: der Klick im Explorer OEFFNETE die Datei, Tab und Breadcrumb
+ * wechselten, und sichtbar blieb die Frage. Eine Aktion, die wirkt, ohne
+ * dass man ihre Wirkung sieht, ist die schlimmste Sorte stillen Fehlschlags.
+ *
+ * Wer eine Datei oeffnet, ein Symbol waehlt oder eine Frage stellt, hat die
+ * Frage beantwortet, indem er anfing zu lesen. Nur fuer diese Sitzung und
+ * nicht im Speicher des Browsers: was er dauerhaft beantwortet, beantwortet
+ * er mit einer der vier Karten oder mit dem Knopf darunter.
+ */
+ const [whyDismissed, setWhyDismissed] = useState(() => {
+ try { return localStorage.getItem('cbm.workspace.setup') === 'done'; }
+ catch { return true; }
+ });
+
+ const [tour, setTour] = useState(undefined);
+ const [tourStep, setTourStep] = useState(0);
+ const [tourMessage, setTourMessage] = useState('');
+ const tourStepRef = useRef(0);
+ tourStepRef.current = tourStep;
+
+ /*
+ * Der Walk hinter einem Vorwaerts-Gang, aufgehoben statt weggeworfen.
+ *
+ * Bis W4d wurde die Antwort von `getClosure` sofort in eine Fuehrung
+ * uebersetzt und danach vergessen. Die Fuehrung ist aber eine Liste, und
+ * eine Liste zeigt keine Tiefe: sie hat die Symbole ohne Datei schon
+ * weggelassen und aus dem Graphen eine Reihe gemacht. Das Graph-Panel
+ * bekommt deshalb den Walk selbst (W4e) und projiziert ihn, statt aus
+ * Schritten einen Graphen zurueckzurechnen, den es nie gab.
+ */
+ const [walk, setWalk] = useState(undefined);
+
+ const [entryOpen, setEntryOpen] = useState(false);
+ const [overview, setOverview] = useState(undefined);
+ const [overviewLoading, setOverviewLoading] = useState(false);
+ const [overviewError, setOverviewError] = useState();
+ const [overviewRevision, setOverviewRevision] = useState(0);
+ const repositoryReading = useRepositorySnapshot(project, overviewRevision);
+ const [entryQuery, setEntryQuery] = useState('');
+ const [entryHits, setEntryHits] = useState(noHits);
+ const [entryStatus, setEntryStatus] = useState('idle');
+ const [entryMessage, setEntryMessage] = useState('');
+ const entryTicket = useRef(0);
+
+ const [marks, setMarks] = useState({ visited: {}, confirmed: {} });
+
+ // -------------------------------------- Der BUG-Assistent und die Aenderung
+
+ /*
+ * Ob der Assistent zu sehen ist, abgeleitet aus dem Reiter.
+ *
+ * Kein eigener Schalter mehr, und das ist der Kern von W8: ein Schalter
+ * neben dem Reiter waere die Stelle, an der beide auseinanderlaufen, und
+ * zwei Flaechen mit je einem eigenen Schalter sind genau der Zustand, in
+ * dem der Nutzer sie am 2026-08-29 uebereinander liegen sah.
+ */
+ const bugOpen = explainOpen && explainTab === 'bug';
+ const [bugTarget, setBugTarget] = useState(undefined);
+ const [bugPathsDto, setBugPathsDto] = useState(undefined);
+ const [bugStatus, setBugStatus] = useState('idle');
+ const [bugMessage, setBugMessage] = useState('');
+ const bugTicket = useRef(0);
+
+ // ------------------------------------------ Das lokale Modell (W5a) ----
+
+ /*
+ * Drei Zustaende und kein vierter: was das Projekt erlaubt, was dieser
+ * Browser eingestellt hat, und was der Prozess auf 4141 gerade von sich
+ * gibt. Die ersten beiden entscheiden ueber das Fragen, der dritte ist die
+ * Antwort auf das Gefragte.
+ *
+ * `llmPolicy` bleibt undefined, bis die Datei des Zielprojekts gelesen ist.
+ * Genau so lange ist das LLM aus, auch wenn die Praeferenz an sagt: siehe
+ * src/llm/llm-state.ts, Regel 3.
+ */
+ const [llmPolicy, setLlmPolicy] = useState(undefined);
+ const llmPreferenceOn = false;
+ const [llmProbe, setLlmProbe] = useState(EMPTY_SIDECAR_READING);
+ const llmProbes = useRef(0);
+
+ /*
+ * Die Modellwahl dieses Browsers (W10), und der Griff, der sofort nachfragt.
+ *
+ * Die Wahl steht als Ref DANEBEN, weil der Probe-Timer sie liest: haenge er
+ * an ihr, wuerde jede Wahl den Timer ab- und neu aufhaengen. Der Griff geht
+ * die andere Richtung: wer waehlt, will die Zahlen des gewaehlten Modells
+ * sehen, ohne drei Sekunden auf die naechste Runde zu warten. Er ruft
+ * dieselbe Probe wie der Timer und landet damit im selben Zaehler; ein
+ * zweiter Weg zum Sidecar waere eine Anfrage, die `__atlasLlm.probes` nicht
+ * mitzaehlt, und der Beweislauf misst an der neuen Flaeche vorbei.
+ */
+ const [selectedModel, setSelectedModel] = useState('');
+ const selectedModelRef = useRef('');
+ selectedModelRef.current = selectedModel;
+
+ // ------------------------------------------- Der Atlas-Chat (W5b) ------
+
+ /*
+ * Der Verlauf dieser Sitzung, und nur dieser.
+ *
+ * Im Speicher und nirgends sonst: kein localStorage, keine Datei, kein
+ * Server. Das ist keine fehlende Funktion, sondern Non-Goal des Contracts.
+ * Ein Verlauf, der den Reload ueberlebt, ist ein Protokoll darueber, was
+ * jemand nicht verstanden hat, und dieses Produkt legt keines an.
+ */
+ const [chatTurns, setChatTurns] = useState([]);
+ /** Martins Kontextregel als Einstellung. Vorgabe: Fokus plus erste Nachbarschaft. */
+ const [chatDepth, setChatDepth] = useState(NEIGHBOR_DEPTH_DEFAULT);
+ const chatTicket = useRef(0);
+ /*
+ * Ob der Chat zu sehen ist, abgeleitet aus dem Reiter.
+ *
+ * Der Zustand, den W7c hier eingefuehrt hat, ist nicht verschwunden, er ist
+ * eine Ebene hoeher gewandert: "hat dieser Leser schon gefragt" steht in
+ * `chatTurns`, "will er die Antworten gerade sehen" im Reiter des
+ * Erklaeren-Bereichs. Der Nutzerbefund vom 2026-08-29 dahinter gilt
+ * unveraendert: Zumachen laesst den Verlauf stehen, und nur `clear` loescht.
+ */
+ const chatOpen = explainOpen && explainTab === 'chat';
+
+ /*
+ * Die Umformulierung des Pseudocode-Blocks.
+ *
+ * `refined` haelt das Ergebnis getrennt vom deterministischen Block, statt
+ * ihn zu ersetzen: das Original muss jederzeit zurueckholbar sein, und ein
+ * ueberschriebener Block waere genau das nicht mehr.
+ */
+ const [refineState, setRefineState] = useState<'idle' | 'running' | 'applied' | 'refused'>('idle');
+ const [refineMessage, setRefineMessage] = useState('');
+
+ /** Der Griff auf das Eingabefeld, fuer den einen Weg zurueck in die Suche. */
+ const commandInputRef = useRef(null);
+
+ /**
+ * Die Grenzen des Vorwaerts-Walks, einmal aus der Adresszeile gelesen.
+ *
+ * Fehlt ein Parameter, entscheidet der Vorgabewert in provider/closure.ts.
+ * Gelesen wird nur beim Start: eine Grenze, die sich waehrend eines Walks
+ * aendert, waere ein Walk, der auf halber Strecke eine andere Frage
+ * beantwortet.
+ */
+ const closureBounds = useMemo(() => {
+ const params = new URLSearchParams(window.location.search);
+ const read = (name: string): number | undefined => {
+ const raw = params.get(name);
+ if (raw === null) {
+ return undefined;
+ }
+ const value = Number.parseInt(raw, 10);
+ return Number.isFinite(value) ? value : undefined;
+ };
+ return { depth: read(CLOSURE_DEPTH_PARAM), cap: read(CLOSURE_CAP_PARAM) };
+ }, []);
+
+ /*
+ * Der Vorwaerts-Walk aus dem Symbol im Fokus (W10b, AC3).
+ *
+ * Nutzerbefund vom 2026-08-30: "und hierarchies werden auch nicht
+ * angezeigt." Die Ursache stand in GalaxyPanel.tsx: die Projektion hing am
+ * WALK, und einen Walk gab es nur nach einem Einstiegs-Spaziergang oder in
+ * einer Fuehrung. Ein Symbol im Fokus reichte nicht, obwohl der Leser genau
+ * dann seinen Aufrufbaum sehen will.
+ *
+ * Drei Entscheidungen stecken darin:
+ *
+ * 1. **Derselbe Closure, dieselben Grenzen.** Gerechnet wird mit
+ * `getClosure` und mit `closureBounds`, also Zeichen fuer Zeichen wie
+ * beim Einstiegs-Spaziergang. Ein zweiter, eigener Weg waere ein zweites
+ * Bild derselben Frage.
+ * 2. **Nur ohne laufenden Spaziergang.** Laeuft einer, ist er gemeint; ihn
+ * zu ueberschreiben, weil der Leser in ihm gerade woanders hinsieht,
+ * waere das Ende des Spaziergangs ohne Ansage.
+ * 3. **Das Ergebnis waehlt keine Ansicht.** Es macht die Hierarchie
+ * moeglich, mehr nicht; die Vorgabe bleibt die Galaxie (Entscheidung 17
+ * im Kopf von GalaxyPanel.tsx).
+ */
+ const markedGraphRange = useMemo(() => markedSourceRange(readerSelection, activePath), [readerSelection, activePath]);
+ const readerFocusRange = useMemo(() => markedGraphRange
+ ?? (caretLine !== undefined && caretLine > 0 ? { startLine: caretLine, endLine: caretLine } : undefined), [markedGraphRange, caretLine]);
+ const activeGalaxySelection = useMemo(() => resolveRepositorySelection(
+ galaxySelectionProject === project ? galaxySelection : undefined, repositoryReading.snapshot,
+ ), [galaxySelectionProject, project, galaxySelection, repositoryReading.snapshot]);
+ const selectionUnavailable = Boolean(galaxySelectionProject === project && galaxySelection && repositoryReading.snapshot && !activeGalaxySelection);
+ const selectionUnavailableText = 'The previous selection is absent from this bounded index snapshot. Choose a source element again to inspect its current evidence.';
+ const selectedGraphSymbol = useMemo(() => hierarchySymbolOf(activeGalaxySelection, project), [activeGalaxySelection, project]);
+ const readerFocusSymbol = (twinSymbol?.projectName === undefined || twinSymbol.projectName === project) && symbolMatchesReader(twinSymbol, activePath, readerFocusRange)
+ ? twinSymbol : symbolMatchesReader(selectedGraphSymbol, activePath, readerFocusRange) ? selectedGraphSymbol : undefined;
+ const hierarchyRoot = workspace === 'galaxy' ? selectedGraphSymbol : readerFocusSymbol;
+ const focusHierarchy = useSelectionHierarchy(provider, ATLAS_WORKSPACE_ROOT, project, hierarchyRoot, closureBounds,
+ workspace === 'galaxy' || (workspace === 'explore' && walk === undefined));
+ const focusWalk = focusHierarchy.walk;
+
+ // ------------------------------------------------------- Projekt -------
+
+ useEffect(() => {
+ const fromUrl = new URLSearchParams(window.location.search).get('project') ?? '';
+ if (fromUrl.length > 0) {
+ setUiLogProject(fromUrl);
+ setProject(fromUrl);
+ setProjectDetail('');
+ return;
+ }
+ let cancelled = false;
+ client
+ .listProjects()
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ const first = result.projects[0];
+ if (first === undefined) {
+ setProjectDetail(messages.statusbar.noProjectIndexed);
+ return;
+ }
+ setUiLogProject(first.name);
+ setProject(first.name);
+ setProjectDetail('');
+ })
+ .catch((error: unknown) => {
+ if (!cancelled) {
+ setServerOk(false);
+ setProjectDetail(messages.statusbar.noProjectError(String(error)));
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [client]);
+
+ // ------------------------------------------------------- Kennzahlen ----
+
+ useEffect(() => {
+ if (project.length === 0) {
+ return;
+ }
+ let cancelled = false;
+ client
+ .indexStatus(project)
+ .then((status) => {
+ if (cancelled) {
+ return;
+ }
+ setServerOk(true);
+ const next: { nodes?: number; edges?: number } = {};
+ if (status.nodes !== undefined) {
+ next.nodes = status.nodes;
+ }
+ if (status.edges !== undefined) {
+ next.edges = status.edges;
+ }
+ setCounts(next);
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setServerOk(false);
+ setCounts({});
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [client, project, overviewRevision]);
+
+ // ------------------------------------------------------- Baum ----------
+
+ useEffect(() => {
+ if (project.length === 0) {
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ const root = await api.tree(project, '');
+ const nextLevels = new Map([['', root]]);
+ const nextExpanded = new Set();
+ const queue = directoryPaths(root);
+ while (queue.length > 0 && nextLevels.size < EAGER_LEVEL_BUDGET) {
+ const dir = queue.shift();
+ if (dir === undefined || nextLevels.has(dir)) {
+ continue;
+ }
+ const level = await api.tree(project, dir);
+ if (cancelled) {
+ return;
+ }
+ nextLevels.set(dir, level);
+ nextExpanded.add(dir);
+ queue.push(...directoryPaths(level));
+ }
+ if (cancelled) {
+ return;
+ }
+ setLevels(nextLevels);
+ setExpanded(nextExpanded);
+ setBudgetHit(queue.length > 0);
+ setTreeError('');
+ setServerOk(true);
+ })().catch((error: unknown) => {
+ if (!cancelled) {
+ setLevels(emptyLevels);
+ setExpanded(new Set());
+ setTreeError(String(error instanceof Error ? error.message : error));
+ setServerOk(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [api, project, overviewRevision]);
+
+ /*
+ * Die zweite Baumquelle: der Coverage-Store.
+ *
+ * Ein eigener Effekt und nicht ein Anhaengsel des Baum-Effekts, aus zwei
+ * Gruenden. Erstens haengen die beiden an verschiedenen Dingen: der Baum an
+ * `/api`, die Coverage an `/rpc`, und ein Ausfall der einen darf die andere
+ * nicht mitnehmen. Zweitens ist die Reihenfolge egal: der Join ist eine
+ * reine Funktion ueber beide Ergebnisse, also darf ankommen, was zuerst
+ * fertig ist.
+ */
+ useEffect(() => {
+ if (project.length === 0) {
+ return;
+ }
+ let cancelled = false;
+ setCoverageAsked(false);
+ loadCoverage(client, project)
+ .then((reading) => {
+ if (cancelled) {
+ return;
+ }
+ setCoverage(reading.index);
+ setCoverageMeta({ ...reading.answer.metadata });
+ setCoverageAsked(true);
+ setCoverageError('');
+ })
+ .catch((error: unknown) => {
+ if (cancelled) {
+ return;
+ }
+ // Kein stiller Rueckfall auf "keine Luecken": ein Baum, der
+ // Vollstaendigkeit behauptet, weil er nicht nachfragen konnte,
+ // waere die teuerste Luege dieses Zyklus.
+ setCoverage(EMPTY_COVERAGE);
+ setCoverageMeta({});
+ setCoverageAsked(false);
+ setCoverageError(error instanceof Error ? error.message : String(error));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [client, project, overviewRevision]);
+
+ /**
+ * Die Vereinigung, aus der der Explorer zeichnet.
+ *
+ * Der Nur-Graph-Weg ist damit weg: `rows` haengt an dieser Karte und nicht
+ * mehr an `levels`. Solange die Coverage-Antwort fehlt, ist die Vereinigung
+ * genau der Graph, und die Notiz unter dem Baum sagt, dass noch niemand
+ * gefragt hat.
+ */
+ const mergedLevels = useMemo(() => mergeCoverageIntoLevels(levels, coverage), [levels, coverage]);
+
+ const rows = useMemo(() => flattenTree(mergedLevels, expanded), [mergedLevels, expanded]);
+
+ const loadLevel = useCallback(
+ async (path: string) => {
+ const level = await api.tree(project, path);
+ setLevels((current) => {
+ const next = new Map(current);
+ next.set(path, level);
+ return next;
+ });
+ },
+ [api, project],
+ );
+
+ const toggleDirectory = useCallback(
+ (row: TreeRow) => {
+ setExpanded((current) => {
+ const next = new Set(current);
+ if (next.has(row.path)) {
+ next.delete(row.path);
+ } else {
+ next.add(row.path);
+ }
+ return next;
+ });
+ if (!levels.has(row.path)) {
+ void loadLevel(row.path).catch((error: unknown) => {
+ // Ein Ordner, den nur die Coverage-Listen kennen, hat im
+ // Graphen keine Ebene. Dass /api/tree ihn nicht kennt, ist
+ // dann der erwartete Fall und kein Baumfehler; die Zeilen
+ // darunter kommen ohnehin aus der Vereinigung.
+ if (mergedLevels.get(row.path)?.synthetic === true) {
+ return;
+ }
+ setTreeError(String(error instanceof Error ? error.message : error));
+ });
+ }
+ },
+ [levels, loadLevel, mergedLevels],
+ );
+
+ // ------------------------------------------------------- Datei oeffnen -
+
+ /**
+ * Eine Datei oeffnen, mit dem Coverage-Befund als erster Instanz.
+ *
+ * Steht die Datei in einer der Listen, die "kein Inhalt" bedeuten, wird gar
+ * nicht erst geladen: der Server liefert Inhalt ausschliesslich ueber einen
+ * Modul-Knoten, und den gibt es fuer eine uebersprungene Datei nicht. Ein
+ * Aufruf, dessen Ausgang schon feststeht, waere eine Anfrage, um eine
+ * Fehlermeldung zu erzeugen, die man vorher kannte.
+ *
+ * Scheitert das Laden trotzdem an einem fehlenden Modul-Knoten, ist das
+ * dieselbe Grenze und nicht ein Ausfall: `unavailable` statt `failed`, mit
+ * der Begruendung, die der Lader selbst geschrieben hat.
+ */
+ const openFile = useCallback(
+ (path: string) => {
+ setWorkspace('explore');
+ /*
+ * Wer eine Datei oeffnet, hat die Frage beantwortet, indem er
+ * anfing zu lesen (Nutzerbefund 2026-08-29, AC6f). Hier und nicht
+ * im Explorer, weil dieselbe Funktion auch die Suche, die Zitate
+ * des Chats, der Walk und jeder Sprung des Twin benutzen: eine
+ * Regel an einer Stelle statt sechs Aufrufen derselben Regel.
+ */
+ setWhyReopened(false);
+ setWhyDismissed(true);
+ setTabs((current) => (current.includes(path) ? current : [...current, path]));
+ setActivePath(path);
+ setReaderSelection(undefined);
+ // Flow steps retain their root across files. Reloading the same
+ // document also keeps ready facts until the caret resolves again.
+ if (!flowPinned.current && documentRef.current?.path !== path) {
+ setTwinSymbol(undefined);
+ setTwinIr(undefined);
+ setTwinStatus('empty');
+ setTwinName(messages.twin.noSymbol);
+ }
+ setImports(undefined);
+ setDocument(undefined);
+ setDocumentOrigin(undefined);
+ loadTicket.current += 1;
+ const ticket = loadTicket.current;
+
+ const record = coverageRef.current.records.get(path);
+ const state: CoverageState = record?.state ?? 'indexed';
+ if (state === 'skipped' || state === 'not-indexed' || state === 'ignored') {
+ setReaderStatus('unavailable');
+ setReaderMessage(readerUnavailableNote(path, state, record?.reason ?? ''));
+ return;
+ }
+
+ setReaderStatus('loading');
+ setReaderMessage(messages.reader.loading(path, READER_RPC_TOOL));
+ loadFileDocument(client, project, path)
+ .then((loaded) => {
+ if (ticket !== loadTicket.current) {
+ return;
+ }
+ setDocument(loaded);
+ setDocumentOrigin({ project, version: `reader-load:${ticket}` });
+ setReaderStatus('ready');
+ })
+ .catch((error: unknown) => {
+ if (ticket !== loadTicket.current) {
+ return;
+ }
+ setDocument(undefined);
+ setReaderStatus(error instanceof FileNotReadableError ? 'unavailable' : 'failed');
+ setReaderMessage(error instanceof Error ? error.message : String(error));
+ });
+ },
+ [client, project],
+ );
+
+ const closeTab = useCallback(
+ (path: string) => {
+ setTabs((current) => {
+ const next = current.filter((entry) => entry !== path);
+ if (path === activePath) {
+ const fallback = next[next.length - 1];
+ if (fallback === undefined) {
+ setActivePath('');
+ setDocument(undefined);
+ setReaderStatus('idle');
+ setReaderMessage(messages.reader.pickFile);
+ } else {
+ openFile(fallback);
+ }
+ }
+ return next;
+ });
+ },
+ [activePath, openFile],
+ );
+
+ /*
+ * Die Frische der offenen Datei.
+ *
+ * Eine eigene Frage je Datei, weil die Antwort nur beim Fragen entsteht:
+ * der Server vergleicht mtime und Groesse der Datei auf der Platte mit dem,
+ * was beim Indexlauf notiert wurde (mcp.c, coverage_path_freshness). Genau
+ * darum meldet sie eine Datei, die seit dem Index geaendert wurde, und
+ * genau darum steht sie nicht schon in den Listen, die beim Start geholt
+ * wurden.
+ *
+ * Ein Fehler hier loescht die Notiz, statt eine alte stehenzulassen: eine
+ * Frische-Angabe ueber die vorige Datei waere schlimmer als keine.
+ */
+ useEffect(() => {
+ if (project.length === 0 || activePath.length === 0) {
+ setOpenCoverage(undefined);
+ return;
+ }
+ coverageTicket.current += 1;
+ const ticket = coverageTicket.current;
+ let cancelled = false;
+ setOpenCoverage(undefined);
+ loadPathCoverage(client, project, activePath)
+ .then((answer) => {
+ if (!cancelled && ticket === coverageTicket.current) {
+ setOpenCoverage(answer);
+ }
+ })
+ .catch(() => {
+ if (!cancelled && ticket === coverageTicket.current) {
+ setOpenCoverage(undefined);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activePath, client, project]);
+
+ // ------------------------------------------------------- Caret-Strecke -
+
+ // Der Caret wandert sofort in die Anzeige (Badge-Puls, data-current) und
+ // erst nach der Ruhezeit in die Frage an den Graphen.
+ useEffect(() => {
+ if (caretLine === undefined) {
+ return;
+ }
+ const timer = window.setTimeout(() => setSettledCaret(caretLine), TWIN_CARET_DEBOUNCE_MS);
+ return () => window.clearTimeout(timer);
+ }, [caretLine]);
+
+ // Ein Dateiwechsel loescht die alte Lage. Der Caret der neuen Datei meldet
+ // sich von selbst, sobald Monaco sein Modell getauscht hat; bis dahin
+ // stehenzulassen, was ueber die vorige Datei bekannt war, waere ein Panel,
+ // das ueber die falsche Datei spricht.
+ useEffect(() => {
+ setCaretLine(undefined);
+ setSettledCaret(undefined);
+ setPointedLine(undefined);
+ }, [activePath]);
+
+ useEffect(() => {
+ if (project.length === 0 || activePath.length === 0 || settledCaret === undefined) {
+ return;
+ }
+ // Waehrend einer Stepper-Sitzung bleibt das Subjekt stehen. Warum,
+ // steht bei `flowPinned`.
+ if (flowPinned.current) {
+ return;
+ }
+ let cancelled = false;
+ twinTicket.current += 1;
+ const ticket = twinTicket.current;
+ const current = () => !cancelled && ticket === twinTicket.current;
+
+ (async () => {
+ const resolved = await provider.resolveSymbolAt(
+ ATLAS_WORKSPACE_ROOT,
+ activePath,
+ settledCaret,
+ { projectName: project },
+ );
+ if (!current()) {
+ return;
+ }
+ if (resolved.kind !== 'ok') {
+ // Jeder der drei Faelle sagt etwas anderes, und keiner davon
+ // heisst "dieses Symbol hat keine Fakten".
+ setTwinSymbol(undefined);
+ setTwinIr(undefined);
+ setTwinName(messages.twin.noSymbol);
+ if (resolved.kind === 'file-not-indexed') {
+ setTwinStatus('not-indexed');
+ setTwinMessage(TWIN_FILE_NOT_INDEXED);
+ setTwinHint(TWIN_FILE_NOT_INDEXED_HINT);
+ } else if (resolved.kind === 'engine-unavailable') {
+ setTwinStatus('failed');
+ setTwinMessage(TWIN_LOAD_FAILED);
+ setTwinHint(resolved.reason);
+ } else {
+ setTwinStatus('empty');
+ setTwinMessage(TWIN_EMPTY_MESSAGE);
+ setTwinHint(TWIN_EMPTY_HINT);
+ }
+ return;
+ }
+
+ const symbol = resolved.symbol;
+ const unchanged =
+ shownSymbol.current?.qualifiedName !== undefined &&
+ shownSymbol.current.qualifiedName === symbol.qualifiedName;
+ if (unchanged && twinIr !== undefined) {
+ // Gleiches Subjekt, neuer Caret: nichts zu holen und nichts zu
+ // melden. Auch nur kurz in den Ladezustand zu gehen waere ein
+ // Blinken, das behauptet, es passiere etwas.
+ return;
+ }
+ setTwinSymbol(symbol);
+ setTwinIr(undefined);
+ setTwinName(symbol.name);
+ setTwinStatus('loading');
+ setTwinMessage(TWIN_LOADING);
+ setTwinHint('');
+ const entry = await irCache.load(symbol);
+ if (!current()) {
+ return;
+ }
+ setTwinIr(entry.ir);
+ setTwinStatus('ready');
+ })().catch((error: unknown) => {
+ if (!current()) {
+ return;
+ }
+ setTwinIr(undefined);
+ setTwinStatus('failed');
+ setTwinMessage(TWIN_LOAD_FAILED);
+ setTwinHint(error instanceof Error ? error.message : String(error));
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ // `twinIr` steht bewusst nicht in der Liste: es aendert sich als Folge
+ // dieses Effekts, und eine Abhaengigkeit darauf waere eine Schleife.
+ // Gelesen wird es nur, um zu entscheiden, ob schon etwas dasteht.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [provider, irCache, project, activePath, settledCaret, flowStep]);
+
+ /**
+ * Einem Ziel folgen: das Subjekt wechselt sofort, der Editor kommt nach.
+ *
+ * Die Fakten kommen aus der Aufloesung in der Zieldatei und nicht aus dem
+ * Klickziel. Warum, steht oben im Kopf dieser Datei.
+ */
+ /**
+ * Die stille Anzeige beim Folgen: das Item, dem gefolgt wurde, ist besucht.
+ *
+ * Vermerkt wird am Symbol, dessen Checkliste die Zeile traegt, und nicht am
+ * Ziel: die Verpflichtung "verstehe den Aufruf von validateUser" gehoert
+ * demjenigen, der aufruft. Findet sich keine Zeile, die auf dieses Ziel
+ * zeigt, wird nichts vermerkt; ein Klick auf etwas, das nicht auf der Liste
+ * steht, ist kein abgearbeiteter Punkt.
+ */
+ const markFollowed = useCallback(
+ (target: SymbolRef) => {
+ const ir = shownIr.current;
+ const owner = ir?.symbol.qualifiedName;
+ if (ir === undefined || owner === undefined || project.length === 0) {
+ return;
+ }
+ const item = ir.checklist.find((entry) =>
+ entry.target !== undefined
+ && ((target.qualifiedName !== undefined
+ && entry.target.qualifiedName === target.qualifiedName)
+ || (target.qualifiedName === undefined && entry.target.uri === target.uri)));
+ if (item === undefined) {
+ return;
+ }
+ setMarks(markVisited(store, project, owner, item.id));
+ },
+ [project, store],
+ );
+
+ const followTarget = useCallback(
+ (target: SymbolRef) => {
+ setWorkspace('explore');
+ setFlowStep(-1);
+ flowPinned.current = false;
+ markFollowed(target);
+ const location = twinLocationOf(target);
+ const keepReadyTwin = location.path.length > 0
+ && keepsReadyTwinForTarget(target, shownSymbol.current, shownIr.current, twinStatus);
+ setTwinName(target.name);
+ if (!keepReadyTwin) {
+ setTwinIr(undefined);
+ setTwinSymbol(undefined);
+ setTwinStatus('loading');
+ setTwinMessage(TWIN_LOADING);
+ setTwinHint('');
+ }
+ if (location.path.length === 0) {
+ setTwinStatus('empty');
+ setTwinMessage(TWIN_EMPTY_MESSAGE);
+ setTwinHint(TWIN_EMPTY_HINT);
+ return;
+ }
+ if (location.path !== activePath) {
+ openFile(location.path);
+ }
+ setReveal((previous) => ({ line: location.line, nonce: (previous?.nonce ?? 0) + 1 }));
+ if (location.path === activePath) setSettledCaret(location.line);
+ },
+ [activePath, markFollowed, openFile, twinStatus],
+ );
+
+ const pointRow = useCallback((row: TwinRow | undefined) => {
+ setPointedLine(row?.siteLine);
+ }, []);
+
+ // ------------------------------------- Flow-Erklaerer und Pseudocode ---
+
+ /**
+ * Eine Zeile oeffnen: Datei anfahren, Zeile offenbaren, Zeile anleuchten.
+ *
+ * Derselbe Weg, den `followTarget` fuer eine Twin-Zeile geht, nur ohne den
+ * Subjektwechsel: eine Pseudocode-Zeile zeigt auf eine Stelle im Text und
+ * behauptet nicht, dass dort ein anderes Symbol das Thema wird. Was dort
+ * steht, sagt der Reader; wer dort steht, sagt die Aufloesung, sobald der
+ * Leser die Sitzung beendet.
+ */
+ const openLine = useCallback(
+ (ref: PseudocodeSourceRef) => {
+ const path = workspacePathOf(ref.uri);
+ if (path.length === 0) {
+ return;
+ }
+ if (path !== activePath) {
+ openFile(path);
+ }
+ setPointedLine(ref.line);
+ setReveal((previous) => ({ line: ref.line, nonce: (previous?.nonce ?? 0) + 1 }));
+ },
+ [activePath, openFile],
+ );
+
+ /**
+ * Einen Schritt anlaufen: Kasten, STEPS-Liste und Editor bewegen sich
+ * zusammen.
+ *
+ * Der Kasten und die Liste folgen dem Index (das Panel rechnet beides aus
+ * demselben Modell), der Editor folgt der Quellangabe der Zeile. Eine
+ * Zeile ohne Quellangabe bewegt den Editor nicht, statt irgendwohin zu
+ * springen.
+ */
+ const stepFlow = useCallback(
+ (index: number) => {
+ const step = flowView?.steps[index];
+ if (step === undefined) {
+ return;
+ }
+ setFlowStep(index);
+ flowPinned.current = true;
+ if (step.line.sourceRef !== undefined) {
+ openLine(step.line.sourceRef);
+ }
+ },
+ [flowView, openLine],
+ );
+
+ /**
+ * Der flow()-Kopf im Twin schaltet den Reiter.
+ *
+ * Steht der Bereich schon auf `flow`, klappt er zu; steht er woanders oder
+ * ist er zu, geht er auf `flow` auf. Das ist dieselbe Bedienung wie vorher
+ * (ein Kopf, ein Klick, auf und zu), und sie kostet seit W8 nichts mehr: der
+ * Schritt bleibt, wo er war, und ein zweiter Klick zeigt wieder dieselbe
+ * Stelle. Bis W8 setzte dieses Zumachen den Stepper zurueck, weil ein
+ * Overlay eine Sitzung war; ein Reiter ist keine.
+ */
+ const toggleFlow = useCallback(() => {
+ setExplainOpen((open) => !(open && explainTabRef.current === 'flow'));
+ setExplainTab('flow');
+ }, []);
+
+ /**
+ * Etwas erklaeren lassen: den Bereich aufmachen, auf DIESEM Reiter.
+ *
+ * Der eine Weg, auf dem jede der fuenf Flaechen aufgeht (AC4). Bis W8 hatte
+ * jede ihren eigenen, und drei davon machten dabei die anderen zu, was der
+ * Grund war, aus dem der Zustand jedes Mal verlorenging. Hier geht nichts zu:
+ * es wird nur umgeschaltet.
+ */
+ const openExplain = useCallback((tab: ExplainTabId) => {
+ setExplainTab(tab);
+ setExplainOpen(true);
+ }, []);
+
+ /**
+ * Den Bereich zuklappen, ohne irgendetwas zu kosten.
+ *
+ * Escape und der Knopf im Kopf gehen denselben Weg. Was hier NICHT steht,
+ * ist der Punkt: kein `setChatTurns([])`, kein `setFlowStep(-1)`, kein
+ * `setTour(undefined)`. Zumachen ist nicht loeschen, und das war der
+ * Nutzerbefund, aus dem W7c den zweiten Knopf des Chats gebaut hat.
+ */
+ const collapseExplain = useCallback(() => {
+ setExplainOpen(false);
+ }, []);
+
+ /*
+ * Der Fakten-Block des aktiven Symbols. Rein aus dem, was schon dasteht:
+ * die Pseudocode-Ansicht kostet keine einzige weitere Anfrage.
+ *
+ * Seit W8c sind es ZWEI Dinge, die schon dastehen, und das zweite ist der
+ * Punkt: die IR des Symbols, und das geladene Layout der Galaxie. Aus dem
+ * Layout liest der Block, was der Index ueber die AUFGERUFENEN Symbole
+ * aufgezeichnet hat (was sie erheben koennen, was sie ihrerseits rufen, was
+ * sie aus der Umgebung lesen), und schreibt es an die Schrittzeile. Es ist
+ * dieselbe Quelle, aus der die Bedeutungssuche ihre Kandidaten nimmt
+ * (`localIndex` weiter oben), und aus demselben Grund: sie ist da, sie ist
+ * eine Antwort fuer das ganze Projekt, und sie noch einmal zu holen waere
+ * ein zweiter Serverweg fuer eine Antwort, die im Speicher liegt. Was das
+ * Layout NICHT hergibt, wird nicht nachgeladen und nicht erfunden; es steht
+ * als `enrichmentAvailable.missing` im Dokument (src/pseudocode/
+ * step-insights.ts).
+ *
+ * Ein Subjektwechsel wirft die Umformulierung weg. Sie gehoert zu genau
+ * einem Block; sie ueber einen Symbolwechsel hinweg stehenzulassen waere
+ * eine Umschreibung der Fakten eines anderen Symbols.
+ */
+ useEffect(() => {
+ const ir = twinIr;
+ setRefineState('idle');
+ setRefineMessage('');
+ if (ir === undefined) {
+ setPseudocode(undefined);
+ return;
+ }
+ setPseudocode(buildPseudocode(
+ { kind: 'symbol', label: ir.symbol.name },
+ { irs: [ir], ...(layout === undefined ? {} : { graph: layout }) },
+ ));
+ }, [layout, twinIr]);
+
+ // Was die Datei um das Symbol herum hereinholt. Zwei Lesungen, eine davon
+ // ist der Text, den der Reader ohnehin schon geladen hat; siehe
+ // src/pseudocode/imports-source.ts.
+ useEffect(() => {
+ const ir = matchingInspectorIr(twinIr, twinSymbol, project, activePath);
+ const uri = ir?.symbol.uri ?? twinTargetOf({ name: baseName(activePath), filePath: activePath, kind: 'module' })?.uri;
+ if (!uri || project.length === 0 || activePath.length === 0) {
+ setImports(undefined);
+ return;
+ }
+ importsTicket.current += 1;
+ const ticket = importsTicket.current;
+ let cancelled = false;
+ void fileImportsFor(provider, ATLAS_WORKSPACE_ROOT, {
+ filePath: activePath,
+ source: documentRef.current?.path === activePath ? documentRef.current.source : undefined,
+ opts: { projectName: project, generation: 1 },
+ })
+ .then((answer) => {
+ if (cancelled || ticket !== importsTicket.current) {
+ return;
+ }
+ setImports(buildImportsGroup({ imports: answer, irs: ir ? [ir] : [], uri }));
+ })
+ .catch(() => {
+ if (!cancelled && ticket === importsTicket.current) {
+ // Eine fehlgeschlagene Import-Lesung ist kein Grund, die
+ // Fakten zu verlieren. Der Data-Block steht ohne sie.
+ setImports(undefined);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activePath, document, project, provider, twinIr, twinSymbol]);
+
+ /*
+ * Der Walk hinter dem Kasten.
+ *
+ * Er laeuft, sobald der Kasten offen ist und ein Subjekt dasteht: das
+ * Design zeigt den Kasten sichtbar, also muss er sich selbst fuellen und
+ * nicht auf einen zweiten Klick warten. Die IRs der erreichten Symbole
+ * kommen aus dem IR-Cache; ein Symbol, das der Leser eben erst besucht hat,
+ * kostet dabei keine Anfrage.
+ */
+ /*
+ * Ein Subjektwechsel wirft den Walk weg, und zwar HIER und nicht im Lader.
+ *
+ * Bis W8 tat es der Lader mit, weil er ohnehin bei jedem Aufschlagen lief.
+ * Seit der Erklaerer ein Reiter ist, laeuft der Lader nicht mehr bei jedem
+ * Aufschlagen (sonst waere der Schritt bei jedem Reiterwechsel weg), und
+ * damit braucht das Wegwerfen eine eigene Stelle. Ohne sie stuende nach
+ * einem Symbolwechsel das Bild des vorigen Symbols da, und das waere die
+ * schlimmste Sorte Fehler dieser Oberflaeche: eine richtige Antwort auf eine
+ * andere Frage.
+ */
+ useEffect(() => {
+ flowLoadedFor.current = '';
+ setFlowView(undefined);
+ setFlowStep(-1);
+ setFlowMessage(twinKey.length === 0 ? FLOW_UNAVAILABLE : FLOW_LOADING);
+ }, [twinKey]);
+
+ useEffect(() => {
+ const symbol = twinSymbol;
+ if (!flowOpen || symbol === undefined || project.length === 0) {
+ return;
+ }
+ // Dasselbe Symbol wird nicht zweimal geholt: sonst kostete jeder
+ // Reiterwechsel den Schritt, an dem der Leser steht.
+ if (flowLoadedFor.current === twinKey) {
+ return;
+ }
+ flowLoadedFor.current = twinKey;
+ flowTicket.current += 1;
+ const ticket = flowTicket.current;
+ let cancelled = false;
+ const current = () => !cancelled && ticket === flowTicket.current;
+ setFlowView(undefined);
+ setFlowStep(-1);
+ setFlowMessage(FLOW_LOADING);
+
+ (async () => {
+ const walk = await getClosure(provider, ATLAS_WORKSPACE_ROOT, symbol, {
+ projectName: project,
+ generation: 1,
+ depth: FLOW_CLOSURE_DEPTH,
+ cap: FLOW_CLOSURE_CAP,
+ });
+ if (!current()) {
+ return;
+ }
+ const closure = closureDocumentOf(walk);
+ // Was ankommt, kommt an. Ein Symbol, dessen IR nicht zu holen war,
+ // wird vom Builder als Gruppe ohne Zeilen gezeigt und im ehrlichen
+ // Block genannt; es wird nicht stillschweigend weggelassen.
+ const irs: SemanticIR[] = [];
+ for (const reached of closure.symbols) {
+ const entry = await irCache.load(reached).catch(() => undefined);
+ if (!current()) {
+ return;
+ }
+ if (entry !== undefined) {
+ irs.push(entry.ir);
+ }
+ }
+ setFlowView(buildFlowView({
+ closure,
+ irs,
+ depth: FLOW_CLOSURE_DEPTH,
+ cap: FLOW_CLOSURE_CAP,
+ }));
+ })().catch(() => {
+ if (current()) {
+ // Der Vermerk faellt zurueck, damit ein zweiter Versuch moeglich
+ // ist. Ein gemerkter Fehlschlag waere ein Symbol, das dieses
+ // Fenster nie wieder zeichnet.
+ flowLoadedFor.current = '';
+ setFlowView(undefined);
+ setFlowMessage(FLOW_UNAVAILABLE);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [flowOpen, irCache, project, provider, twinKey, twinSymbol]);
+
+ // ------------------------------------------------------- Galaxie -------
+
+ const onLayout = useCallback((loaded: GraphData) => {
+ setLayout(loaded);
+ }, [project]);
+
+ /**
+ * Ein Klick in die Galaxie.
+ *
+ * Er geht durch denselben `followTarget` wie ein Klick im Twin: Datei
+ * oeffnen, Zeile anfahren, und was dort steht, sagt die Aufloesung. Ein
+ * Knoten ohne Datei kommt hier gar nicht an; das Panel sagt selbst, warum.
+ */
+ const openGalaxyNode = useCallback(
+ (node: GraphNode) => {
+ setGalaxySelection(node);
+ setGalaxySelectionProject(project);
+ const target = targetRefOfNode(node);
+ if (target === undefined) {
+ return;
+ }
+ followTarget(target);
+ },
+ [followTarget, project],
+ );
+
+ // ------------------------------------------------- Bedeutungssuche -----
+
+ /**
+ * Wie oft der Index ein Symbol erreicht, aus dem geladenen Layout.
+ *
+ * Die flache Suchform des Servers traegt keine solche Spalte
+ * des Fan-in; das Layout traegt sie an jedem Knoten. Solange
+ * keines geladen ist, hat kein Kandidat eine Zahl, und das entscheidet nur
+ * bei Gleichstand.
+ */
+ const fanIn = useMemo(() => {
+ const readings = new Map();
+ for (const node of layout?.nodes ?? []) {
+ const qualifiedName = node.qualified_name;
+ if (qualifiedName !== undefined && node.in_calls !== undefined) {
+ readings.set(qualifiedName, node.in_calls);
+ }
+ }
+ return readings;
+ }, [layout]);
+
+ const fanInOf = useCallback(
+ (hit: { qualifiedName?: string }) => fanIn.get(hit.qualifiedName ?? '') ?? 0,
+ [fanIn],
+ );
+
+ /**
+ * Was dieses Fenster schon weiss, als Kandidatenmenge fuer die Suche.
+ *
+ * Zwei Quellen, beide schon im Speicher, beide ohne einen Serverweg: die
+ * Knoten der Galaxie (Name, qualifizierter Name, Datei, Zeile) und die
+ * Dateien des Baums. Warum daraus Vorschlaege werden duerfen und was sie
+ * NICHT behaupten, steht in src/search/local-suggestions.ts.
+ *
+ * Gemerkt, weil daran der Sofort-Vorschlag zu jedem Tastendruck haengt: die
+ * Menge neu zu bauen, sooft jemand einen Buchstaben tippt, waere die eine
+ * Rechnung, die diesen Weg wieder langsam machen wuerde.
+ */
+ const localIndex = useMemo(() => {
+ const nodes = layout?.nodes ?? [];
+ if (nodes.length === 0 && rows.length === 0) {
+ return EMPTY_LOCAL_INDEX;
+ }
+ return {
+ symbols: nodes
+ .filter((node) => node.name.length > 0)
+ .map((node) => ({
+ name: node.name,
+ qualifiedName: node.qualified_name,
+ kind: symbolKindOf(node.label),
+ filePath: node.file_path,
+ line: node.start_line,
+ })),
+ files: rows.filter((row) => row.kind === 'file').map((row) => row.path),
+ };
+ }, [layout, rows]);
+
+ const localPool = useMemo(() => localCandidates(localIndex), [localIndex]);
+
+ /*
+ * Woran die Beispiele der Kommandozeile haengen (AC6c).
+ *
+ * Ein Name aus dem geladenen Index und keine feste Liste: ein Beispiel mit
+ * einem Symbol, das dieses Projekt nicht hat, waere eine Einladung ins
+ * Leere. Gerechnet aus `localIndex.symbols`, also aus den Knoten des
+ * Layouts, weil die den Namen UND die Art tragen; die Dateien des Baums
+ * bleiben aussen vor, denn eine Datei ruft nichts, und zwei der drei
+ * Beispiele fragen nach Aufrufen. Steht nichts, gibt es keine Beispiele.
+ */
+ const exampleSymbol = useMemo(
+ () => exampleSymbolOf(localIndex.symbols.map((hit) => ({ name: hit.name, kind: hit.kind }))),
+ [localIndex],
+ );
+ const commandExamples = useMemo(
+ () => (exampleSymbol === undefined ? [] : commandExamplesFor(exampleSymbol)),
+ [exampleSymbol],
+ );
+
+ /**
+ * Die Zeilen, die sofort dastehen koennen, ohne dass jemand gefragt wird.
+ *
+ * Erst der Praefix-Cache (die vorige Antwort des Index, neu gerankt), dann
+ * das schon Geladene, und beides in EINEN Rang: zwei Listen zu mischen
+ * hiesse, eine zweite Ordnung neben der einen zu haben, die dieses Produkt
+ * hat.
+ */
+ const instantHits = useCallback(
+ (query: string): RankedHit[] => {
+ const cached = prefixCache.current;
+ const extendsCached =
+ cached !== undefined
+ && cached.complete
+ && query.length > cached.query.length
+ && query.startsWith(cached.query);
+ if (extendsCached) {
+ searchStats.current.prefixCacheHits += 1;
+ }
+ const pool = extendsCached
+ ? [...(cached?.candidates ?? []), ...localPool]
+ : localPool;
+ return pool.length === 0
+ ? noHits
+ : rankLocalCandidates(pool, query, fanInOf, MAX_SEARCH_ROWS);
+ },
+ [fanInOf, localPool],
+ );
+
+ /*
+ * Der Sofort-Vorschlag als Ref, und nicht als Abhaengigkeit des Effekts.
+ *
+ * Er haengt am Baum und an der Galaxie, und beide wachsen waehrend des
+ * Ladens weiter. Stuende er in der Abhaengigkeitsliste, wuerde jede
+ * nachgeladene Baumebene die laufende Suche zuruecksetzen und noch einmal
+ * fragen. Gelesen wird er beim Tastendruck, und dann ist er auf dem Stand
+ * dieses Bildes.
+ */
+ const instantRef = useRef(instantHits);
+ instantRef.current = instantHits;
+
+ /*
+ * Die Suche, in zwei Schritten statt in einem.
+ *
+ * Nutzerbefund vom 2026-08-29: die Vorschlaege erscheinen zu langsam. Bis
+ * dahin lief alles hintereinander: tippen, 200 ms Entprellung, ein Weg zum
+ * Server je Wort, dann die erste Zeile. Der Bildschirm blieb in dieser Zeit
+ * leer, obwohl die Antwort auf einen guten Teil der Frage schon im Browser
+ * lag.
+ *
+ * Jetzt passieren zwei Dinge, und die Reihenfolge ist die Aussage:
+ *
+ * 1. **Sofort**, im selben Bild wie der Tastendruck, stehen die
+ * vorlaeufigen Zeilen da: aus der vorigen Antwort des Index, wenn das
+ * Wort nur verlaengert wurde, sonst aus Baum und Galaxie. Sie tragen
+ * ihre Marke, damit niemand sie fuer die Antwort haelt.
+ * 2. **Nach der Entprellung** geht die Frage an den Index, mit einem
+ * Abbruch am Hals. Liefert die Antwort keine Treffer, bleiben die
+ * geladenen Vorschlaege erhalten und als geladen gekennzeichnet.
+ *
+ * Die Ticketpruefung bleibt und ist die eigentliche Zusicherung: eine
+ * Antwort, die zu spaet kommt, wird verworfen und nicht angezeigt. Der
+ * Abbruch spart zusaetzlich den Weg, den niemand mehr liest.
+ */
+ useEffect(() => {
+ const query = command.trim();
+ // Retire the previous response before the next debounce window begins.
+ searchTicket.current += 1;
+ const ticket = searchTicket.current;
+ searchAbort.current?.abort();
+ searchAbort.current = undefined;
+ if (project.length === 0 || !isSearchable(query, SEARCH_MIN_QUERY)) {
+ setHits(noHits);
+ setAnsweredQuery('');
+ setHitSource('index');
+ setSearchStatus('ready');
+ setSearchMessage('');
+ return;
+ }
+
+ // Schritt 1: was ohne Serverweg zu haben ist, und zwar jetzt.
+ const instant = instantRef.current(query);
+ setHits(instant);
+ setSelectedHit(0);
+ setAnsweredQuery(query);
+ setHitSource('loaded');
+ setSearchStatus('searching');
+ setSearchMessage('');
+ if (instant.length > 0) {
+ searchStats.current.localFirst += 1;
+ }
+
+ // Schritt 2: die Frage an den Index, sobald die Zeile still steht.
+ const timer = window.setTimeout(() => {
+ searchAbort.current?.abort();
+ const controller = new AbortController();
+ searchAbort.current = controller;
+ searchStats.current.serverRequests += 1;
+ const started = performance.now();
+ searchByMeaning(
+ provider,
+ ATLAS_WORKSPACE_ROOT,
+ query,
+ { projectName: project, signal: controller.signal },
+ fanInOf,
+ )
+ .then((answer) => {
+ const elapsed = Math.round(performance.now() - started);
+ if (answer.aborted) {
+ searchStats.current.abortedRequests += 1;
+ return;
+ }
+ if (ticket !== searchTicket.current) {
+ searchStats.current.staleDropped += 1;
+ return;
+ }
+ if (ticket < searchStats.current.appliedTicket) {
+ // Kann nicht passieren, solange die Zeile darueber
+ // greift. Steht trotzdem hier, weil eine Zusicherung,
+ // die niemand nachrechnet, keine ist.
+ searchStats.current.staleAnswerWins = true;
+ return;
+ }
+ searchStats.current.appliedTicket = ticket;
+ searchStats.current.serverRoundtripMs = elapsed;
+ searchStats.current.roundtrips.push(elapsed);
+ prefixCache.current = {
+ query,
+ candidates: answer.candidates,
+ complete: answer.complete,
+ };
+ const settled = settledSearchHits(answer.hits, instant);
+ setHits(settled.hits);
+ setSelectedHit(0);
+ setAnsweredQuery(query);
+ setHitSource(settled.source);
+ setSearchStatus('ready');
+ setSearchMessage('');
+ })
+ .catch((error: unknown) => {
+ if (controller.signal.aborted) {
+ searchStats.current.abortedRequests += 1;
+ return;
+ }
+ if (ticket !== searchTicket.current) {
+ searchStats.current.staleDropped += 1;
+ return;
+ }
+ setHits(noHits);
+ setAnsweredQuery(query);
+ setHitSource('index');
+ setSearchStatus('failed');
+ setSearchMessage(
+ `${SEARCH_FAILED}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ });
+ }, SEARCH_DEBOUNCE_MS);
+ return () => {
+ window.clearTimeout(timer);
+ searchTicket.current += 1;
+ searchAbort.current?.abort();
+ };
+ }, [command, project, provider, fanInOf]);
+
+ const closeSearch = useCallback(() => {
+ window.dispatchEvent(new Event(CLOSE_COMMAND_SEARCH_EVENT));
+ searchTicket.current += 1;
+ searchAbort.current?.abort();
+ searchAbort.current = undefined;
+ setCommand('');
+ setHits(noHits);
+ setAnsweredQuery('');
+ setHitSource('index');
+ setSearchStatus('ready');
+ setSearchMessage('');
+ }, []);
+
+ /**
+ * Einen Treffer waehlen: Datei oeffnen, Twin folgen, Galaxie hinterher.
+ *
+ * Die Kamerafahrt steht hier nicht: sie haengt am Twin-Subjekt, und das
+ * wechselt, sobald die Aufloesung in der Zieldatei da ist. Sie von hier aus
+ * zusaetzlich anzustossen hiesse, zweimal auf dasselbe zu zeigen und beim
+ * zweiten Mal auf den Treffer statt auf das aufgeloeste Symbol.
+ */
+ const chooseHit = useCallback(
+ (index: number) => {
+ const chosen = hits[index];
+ if (chosen === undefined) {
+ return;
+ }
+ const target = twinTargetOf({
+ name: chosen.hit.name,
+ qualifiedName: chosen.hit.qualifiedName,
+ kind: chosen.hit.kind,
+ filePath: chosen.hit.filePath,
+ startLine: chosen.hit.line,
+ });
+ if (target === undefined) {
+ setSearchMessage(SEARCH_NO_FILE);
+ return;
+ }
+ closeSearch();
+ followTarget(target);
+ },
+ [closeSearch, followTarget, hits],
+ );
+
+ // ------------------------------- Warum, Fuehrungen und die Checkliste ---
+
+ // Was dieser Browser ueber dieses Projekt weiss, beim Projektwechsel neu
+ // gelesen. Beides ist an das Projekt gebunden und an nichts sonst.
+ useEffect(() => {
+ setWhyAnswer(readWhyAnswer(store, project));
+ setMarks(readUnderstanding(store, project));
+ setWhyReopened(false);
+ setTour(undefined);
+ setTourStep(0);
+ setTourMessage('');
+ setWalk(undefined);
+ }, [project, store]);
+
+ /**
+ * Wann die Frage dasteht.
+ *
+ * Von selbst nur im leeren Editorbereich und nur, solange dieser Browser
+ * fuer dieses Projekt keine Antwort kennt. Waehrend einer laufenden
+ * Fuehrung nie: die Fuehrung ist die Antwort, die gerade ausgefuehrt wird.
+ * Von Hand ueber das Menue jederzeit, und dann auch ueber einer offenen
+ * Datei, weil der Wiederaufruf sonst genau dann nicht ginge, wenn jemand
+ * mitten im Lesen den Modus wechseln will.
+ */
+ const whyVisible =
+ tour === undefined
+ && !entryOpen
+ && !bugOpen
+ && project.length > 0
+ && !whyDismissed
+ && (whyReopened || (!whyAnswer.asked && activePath.length === 0));
+
+ /*
+ * Der Stand fuer den Tastatur-Griff, bei jedem Bild neu geschrieben.
+ *
+ * Hier und nicht weiter unten, weil `whyVisible` hier entsteht: die Liste
+ * soll an der Stelle stehen, an der ihr letzter Bestandteil bekannt ist,
+ * damit niemand sie fuer vollstaendig haelt, waehrend eine Flaeche fehlt.
+ */
+ keyboardGuardRef.current = {
+ /*
+ * Nur die zwei Flaechen, die die Tastatur wirklich fuehren: die Hilfe
+ * nimmt Escape, der Einstiegsdialog hat sein eigenes Suchfeld.
+ *
+ * Der Erklaeren-Bereich stand hier bis W8 mit drin, solange er ein
+ * Overlay ueber dem Editor war. Als ZONE gehoert er nicht mehr dazu: er
+ * liegt neben dem Reader, die Kommandozeile ist sichtbar, und wer bei
+ * offenem Flow lostippt, meint die Zeile, die er sieht. Genau diese
+ * Lesart hatten die Frage nach dem Warum, der Assistent und die
+ * Aenderungsansicht schon vorher, und aus demselben Grund.
+ */
+ /*
+ * Seit W10 sind es drei: das Einstellungen-Panel fuehrt die Tastatur
+ * genauso wie die Hilfe (Escape schliesst es) und hat ausserdem ein
+ * eigenes Eingabefeld. Ohne diesen Eintrag fielen Buchstaben, die
+ * jemand vor dem offenen Panel tippt, in die Kommandozeile dahinter.
+ */
+ overlayOpen: helpOpen || entryOpen || settingsOpen || projectsOpen,
+ walkRunning: tour !== undefined,
+ };
+
+ const closeTour = useCallback(() => {
+ setTour(undefined);
+ setTourStep(0);
+ // Mit der Fuehrung geht auch der Walk: das Graph-Panel wuerde sonst die
+ // Hierarchie einer Frage zeigen, die der Leser beendet hat.
+ setWalk(undefined);
+ }, []);
+
+ /**
+ * Der Vorwaerts-Walk ab einem gewaehlten Symbol.
+ *
+ * Zuerst wird die Wahl aufgeloest und erst danach gelaufen. Eine Suchzeile
+ * und ein Eintrag der Zusammenfassung nennen eine Stelle, kein Symbol; der
+ * Walk fragt den Graphen nach den Aufrufen eines qualifizierten Namens, und
+ * die Wurzel muss deshalb das sein, was der Index an dieser Stelle kennt.
+ * Scheitert die Aufloesung, wird mit dem gewaehlten Ziel gelaufen, damit
+ * eine Wahl nicht folgenlos bleibt; was dabei fehlt, sagt der Walk selbst.
+ */
+ const startEntryWalk = useCallback(
+ async (chosen: { name: string; qualifiedName?: string; kind: SymbolRef['kind']; filePath: string; line?: number }) => {
+ setEntryOpen(false);
+ setTourMessage('');
+ const fallback = twinTargetOf({
+ name: chosen.name,
+ qualifiedName: chosen.qualifiedName,
+ kind: chosen.kind,
+ filePath: chosen.filePath,
+ startLine: chosen.line,
+ });
+ try {
+ let root = fallback;
+ if (chosen.line !== undefined) {
+ const resolved = await provider.resolveSymbolAt(
+ ATLAS_WORKSPACE_ROOT,
+ chosen.filePath,
+ chosen.line,
+ { projectName: project },
+ );
+ if (resolved.kind === 'ok') {
+ root = resolved.symbol;
+ }
+ }
+ if (root === undefined) {
+ setTourMessage(messages.tour.failedNoFile(chosen.name));
+ return;
+ }
+ const closure = await getClosure(provider, ATLAS_WORKSPACE_ROOT, root, {
+ projectName: project,
+ generation: 1,
+ ...(closureBounds.depth === undefined ? {} : { depth: closureBounds.depth }),
+ ...(closureBounds.cap === undefined ? {} : { cap: closureBounds.cap }),
+ });
+ setWalk(closure);
+ setTour(entryWalkTour(closure));
+ setTourStep(0);
+ openExplain('walk');
+ } catch (error) {
+ setTour(undefined);
+ setWalk(undefined);
+ setTourMessage(`${TOUR_FAILED}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ },
+ [closureBounds, openExplain, project, provider],
+ );
+
+ const chooseIntent = useCallback(
+ (intent: WhyIntent) => {
+ setWhyAnswer(recordWhyAnswer(store, project, intent));
+ setWhyReopened(false);
+ if (intent === 'understand') {
+ changeWorkspace('architecture');
+ } else if (intent === 'entry') {
+ setEntryOpen(true);
+ } else if (intent === 'bug') {
+ openExplain('bug');
+ } else if (intent === 'change') {
+ changeWorkspace('explore');
+ }
+ },
+ [openExplain, project, changeWorkspace, store],
+ );
+
+ const declineWhy = useCallback(() => {
+ // Der Knopf merkt sich nur die Antwort. Kein Profilwechsel: wer nichts
+ // gesagt hat, hat nichts ueber seine Lesetiefe gesagt.
+ setWhyAnswer(recordWhyAnswer(store, project));
+ setWhyReopened(false);
+ setWhyDismissed(true);
+ }, [project, store]);
+
+ /**
+ * Die Frage weglegen, weil der Leser angefangen hat zu lesen.
+ *
+ * Anders als der Knopf darunter schreibt das NICHTS in den Speicher dieses
+ * Browsers: eine Datei zu oeffnen ist keine Antwort auf "warum bist du
+ * hier", sondern der Beweis, dass die Frage gerade nicht dran ist. Beim
+ * naechsten Projekt oder nach dem Menuepunkt steht sie wieder da.
+ */
+ const dismissWhy = useCallback(() => {
+ setWhyReopened(false);
+ setWhyDismissed(true);
+ }, []);
+
+ // Ein Schritt bewegt den Reader. Die beiden Rueckrufe liegen in Refs, damit
+ // dieser Effekt nur an Schritt und Fuehrung haengt: `followTarget` wechselt
+ // seine Identitaet, sobald die geoeffnete Datei wechselt, was genau die
+ // Folge dieses Effekts ist, und eine Abhaengigkeit darauf waere eine
+ // Schleife.
+ const followRef = useRef(followTarget);
+ followRef.current = followTarget;
+ const openRef = useRef(openFile);
+ openRef.current = openFile;
+
+ useEffect(() => {
+ const step = tour?.document.steps[tourStep];
+ if (step === undefined) {
+ return;
+ }
+ const primary = step.primary;
+ if (primary.kind === 'symbol') {
+ const target = twinTargetOf({
+ name: primary.name,
+ qualifiedName: primary.qualifiedName,
+ kind: primary.symbolKind,
+ filePath: primary.filePath,
+ startLine: primary.line,
+ });
+ if (target !== undefined) {
+ followRef.current(target);
+ return;
+ }
+ }
+ openRef.current(primary.filePath);
+ }, [tour, tourStep]);
+
+ /**
+ * Ein betretener Schritt ist ein besuchtes Item.
+ *
+ * Vermerkt wird erst, wenn die IR des Schritt-Symbols da ist: vorher gibt es
+ * keine Checkliste, und ein Vermerk auf eine Id, die dieses Symbol gar nicht
+ * hat, waere ein Haken an nichts. Welches Item, entscheidet
+ * `markableItemId` aus dem portierten tour-model: das erste core-logic-Item,
+ * so wie im Referenzprojekt.
+ */
+ useEffect(() => {
+ if (tour === undefined || twinIr === undefined || project.length === 0) {
+ return;
+ }
+ const owner = twinIr.symbol.qualifiedName;
+ if (owner === undefined) {
+ return;
+ }
+ const state = understandingOf(readUnderstanding(store, project), owner, twinIr.checklist);
+ const item = markableItemId(state.items);
+ if (item === undefined) {
+ return;
+ }
+ setMarks(markVisited(store, project, owner, item));
+ }, [tour, twinIr, project, store]);
+
+ /** Die Zusammenfassung, sobald der Einstiegsdialog sie braucht. */
+ useEffect(() => {
+ if ((!entryOpen && workspace !== 'architecture') || project.length === 0) {
+ return;
+ }
+ let cancelled = false;
+ setOverviewLoading(true);
+ setOverviewError(undefined);
+ provider
+ .architectureOverview(ATLAS_WORKSPACE_ROOT, { projectName: project, generation: 1 })
+ .then((loaded) => {
+ if (!cancelled) {
+ setOverview(loaded);
+ setOverviewLoading(false);
+ }
+ })
+ .catch((error: unknown) => {
+ if (!cancelled) {
+ setOverviewLoading(false);
+ setOverviewError(error instanceof Error ? error.message : String(error));
+ setEntryMessage(messages.entry.overviewFailed(
+ error instanceof Error ? error.message : String(error),
+ ));
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [entryOpen, workspace, project, provider, overviewRevision]);
+
+ // Die Suche des Dialogs ist dieselbe Suche wie in der Kommandozeile, mit
+ // derselben Entprellung. Zwei Rangfolgen fuer dasselbe Wort waeren zwei
+ // Antworten auf eine Frage.
+ useEffect(() => {
+ const query = entryQuery.trim();
+ if (!entryOpen || project.length === 0 || !isSearchable(query, SEARCH_MIN_QUERY)) {
+ setEntryHits(noHits);
+ setEntryStatus('idle');
+ return;
+ }
+ setEntryStatus('searching');
+ const timer = window.setTimeout(() => {
+ entryTicket.current += 1;
+ const ticket = entryTicket.current;
+ findByMeaning(provider, ATLAS_WORKSPACE_ROOT, query, { projectName: project }, fanInOf)
+ .then((ranked) => {
+ if (ticket !== entryTicket.current) {
+ return;
+ }
+ setEntryHits(ranked);
+ setEntryStatus('ready');
+ setEntryMessage(ranked.length === 0 ? messages.entry.noAnswer(query) : '');
+ })
+ .catch((error: unknown) => {
+ if (ticket !== entryTicket.current) {
+ return;
+ }
+ setEntryHits(noHits);
+ setEntryStatus('failed');
+ setEntryMessage(
+ `${SEARCH_FAILED}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ });
+ }, SEARCH_DEBOUNCE_MS);
+ return () => window.clearTimeout(timer);
+ }, [entryOpen, entryQuery, fanInOf, project, provider]);
+
+ const offered = useMemo(() => entryRows(overview), [overview]);
+ const entryHitRows = useMemo(() => searchRows(entryHits), [entryHits]);
+
+ const chooseFlagged = useCallback(
+ (key: string) => {
+ const row = offered.rows.find((entry) => entry.key === key);
+ const target = row?.target;
+ if (target?.filePath === undefined) {
+ return;
+ }
+ void startEntryWalk({
+ name: target.name,
+ qualifiedName: target.qualifiedName,
+ kind: target.kind,
+ filePath: target.filePath,
+ line: target.startLine,
+ });
+ },
+ [offered, startEntryWalk],
+ );
+
+ const chooseEntryHit = useCallback(
+ (index: number) => {
+ const chosen = entryHits[index];
+ if (chosen === undefined) {
+ return;
+ }
+ if (chosen.hit.filePath === undefined || chosen.hit.filePath.length === 0) {
+ setEntryMessage(SEARCH_NO_FILE);
+ return;
+ }
+ void startEntryWalk({
+ name: chosen.hit.name,
+ qualifiedName: chosen.hit.qualifiedName,
+ kind: chosen.hit.kind,
+ filePath: chosen.hit.filePath,
+ line: chosen.hit.line,
+ });
+ },
+ [entryHits, startEntryWalk],
+ );
+
+ // ------------------------- Der BUG-Assistent: Ziel, Lesung, Hop-Klick ---
+
+ /**
+ * Das Subjekt, das der Assistent uebernimmt.
+ *
+ * Das des Twins, und nur bei einem echten Symbolwechsel: ein Assistent, der
+ * sich das erste Symbol merkt, das er gesehen hat, vergliche noch Pfade in
+ * eine Funktion, die der Leser vor zehn Minuten verlassen hat, und nichts auf
+ * dem Bildschirm sagte es. Umgekehrt darf ein Caret, der innerhalb desselben
+ * Symbols wandert, die Lesung nicht neu anstossen.
+ */
+ useEffect(() => {
+ if (!bugOpen) {
+ return;
+ }
+ const next: BugPathNode | undefined =
+ twinSymbol === undefined
+ ? undefined
+ : {
+ name: twinSymbol.name,
+ qualifiedName: twinSymbol.qualifiedName,
+ filePath: workspacePathOf(twinSymbol.uri),
+ };
+ setBugTarget((current) =>
+ current?.name === next?.name && current?.qualifiedName === next?.qualifiedName
+ ? current
+ : next);
+ }, [bugOpen, twinSymbol]);
+
+ useEffect(() => {
+ if (!bugOpen || project.length === 0 || bugTarget === undefined) {
+ return;
+ }
+ bugTicket.current += 1;
+ const ticket = bugTicket.current;
+ setBugStatus('loading');
+ setBugMessage('');
+ bugPaths(client, api, bugTarget, { project })
+ .then((paths) => {
+ if (ticket !== bugTicket.current) {
+ return;
+ }
+ setBugPathsDto(paths);
+ setBugStatus('ready');
+ })
+ .catch((error: unknown) => {
+ if (ticket !== bugTicket.current) {
+ return;
+ }
+ setBugPathsDto(undefined);
+ setBugStatus('failed');
+ setBugMessage(error instanceof Error ? error.message : String(error));
+ });
+ }, [api, bugOpen, bugTarget, client, project]);
+
+ /**
+ * Ein Klick auf einen Hop.
+ *
+ * Der Name wird jetzt aufgeloest und nicht vorher: was in einer Aufzeichnung
+ * steht, hat irgendwessen Rekorder geschrieben, und ein gespeicherter
+ * SymbolRef ohne nodeId waere die Behauptung, dieses Symbol sei nicht
+ * indiziert. Loest der Index nichts auf, wird nichts geoeffnet und der
+ * Assistent sagt es.
+ */
+ const openHop = useCallback(
+ (hop: BugPathNode) => {
+ setBugMessage('');
+ void resolveHop(provider, ATLAS_WORKSPACE_ROOT, hop, { projectName: project })
+ .then((symbol) => {
+ if (symbol === undefined) {
+ setBugMessage(messages.wizard.unresolvedHop(hop.name));
+ return;
+ }
+ followRef.current(symbol);
+ })
+ .catch((error: unknown) => {
+ setBugMessage(error instanceof Error ? error.message : String(error));
+ });
+ },
+ [project, provider],
+ );
+
+ // ------------------------------------------ Das lokale Modell (W5a) ----
+
+ /*
+ * Die Praeferenz dieses Browsers, beim Projektwechsel neu gelesen, so wie
+ * die Antwort auf die Warum-Frage und die Vermerke. Sie haengt am Projekt
+ * und an nichts sonst.
+ */
+ useEffect(() => {
+ setLlmPolicy(undefined);
+ setLlmProbe(EMPTY_SIDECAR_READING);
+ // Die Modellwahl und die Anzeige haengen am selben Projekt und werden
+ // deshalb hier mitgelesen. Beides ist pro Projekt gefuehrt, wie die
+ // Zonenmasse und die Vermerke: ein anderes Repository ist eine andere
+ // Frage und darf eine andere Antwort haben.
+ setSelectedModel(readModelPreference(store, project).id);
+ setDisplay(loadDisplaySettings(store, project));
+ }, [project, store]);
+
+ /*
+ * Die committete Policy des ZIELprojekts, einmal je Projekt.
+ *
+ * Sie wird gelesen, sobald ein Projekt dasteht, und nicht erst, wenn jemand
+ * den Schalter anfasst. Der Grund ist AC4 selbst: der Schalter soll
+ * WIRKUNGSLOS sein und das auch sagen, bevor ihn jemand drueckt. Eine
+ * Policy, die erst beim Einschalten gelesen wird, waere eine, die man einmal
+ * umgehen kann.
+ *
+ * Das ist eine Anfrage an den Graph-Server, nicht an den Sidecar: sie geht
+ * ueber denselben Weg wie jede Datei dieses Frontends und sagt ueber 4141
+ * nichts.
+ */
+ useEffect(() => {
+ if (project.length === 0) {
+ return;
+ }
+ let cancelled = false;
+ readLlmPolicy(client, project)
+ .then((reading) => {
+ if (!cancelled) {
+ setLlmPolicy(reading);
+ }
+ })
+ .catch((error: unknown) => {
+ if (!cancelled) {
+ // Kein stiller Rueckfall auf "erlaubt": wer die Datei nicht
+ // lesen konnte, weiss nicht, ob dort eine Sperre steht.
+ setLlmPolicy({
+ verdict: 'unreadable',
+ path: '.codeatlas/policy.json',
+ detail: error instanceof Error ? error.message : String(error),
+ });
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [client, project]);
+
+ // Browser models have their own explicit opt-in. Saved sidecar settings cannot enable them.
+ const llmMode = resolveLlmState(llmPolicy?.verdict, false);
+
+ const llmState: SidecarState =
+ llmMode === 'disabled-by-policy' ? 'disabled-by-policy' : llmMode === 'off' ? 'off' : llmProbe.state;
+ const llmFacts = llmState === 'ready' ? llmProbe.facts : undefined;
+ const llmModel = llmFacts?.model ?? '';
+ /*
+ * Die Liste des Cache-Verzeichnisses, und ob eine Auswahl daraus ueberhaupt
+ * wirkt. Beides gilt nur, solange der Prozess wirklich antwortet: eine Liste
+ * von vorhin unter einem gestorbenen Sidecar waere eine Auswahl ohne
+ * Gegenstelle.
+ */
+ const llmRouter = llmState === 'ready' && llmProbe.router;
+ const llmModels: readonly CacheModel[] = llmState === 'ready' ? llmProbe.models : NO_CACHE_MODELS;
+
+ /**
+ * Das Modell, das eine Chat-Anfrage mitschickt, oder nichts.
+ *
+ * Nur im Router-Modus: ein Einzel-Server ignoriert ein fremdes `model`-Feld
+ * stillschweigend (gemessen, siehe src/llm/sidecar.ts), und ein Feld, das
+ * nichts bewirkt, mitzuschicken hiesse, sich auf eine Wirkung zu verlassen,
+ * die es nicht gibt. Ohne Wahl bleibt der Rumpf der Anfrage genau der, den
+ * er vor W10 hatte.
+ */
+ const requestModel = llmRouter && selectedModel.length > 0 ? selectedModel : undefined;
+ const selectedModelName = requestModel === undefined
+ ? llmModel
+ : llmModels.find((model) => model.id === requestModel)?.name ?? llmModel;
+
+ /** Die Wahl umlegen: gemerkt, und sofort nachgefragt. */
+ const chooseModel = useCallback(
+ (id: string) => {
+ const selected = recordModelPreference(store, project, id).id;
+ /* The immediate probe below runs before React necessarily renders
+ * the new state. Keep its request and the next explicit AI request
+ * on the same router id. */
+ selectedModelRef.current = selected;
+ setSelectedModel(selected);
+ },
+ [project, store],
+ );
+
+ /** Eine Anzeige-Einstellung umlegen: gemerkt, und sofort wirksam. */
+ const changeDisplay = useCallback(
+ (next: GraphDisplaySettings) => {
+ setDisplay(saveDisplaySettings(store, project, next));
+ },
+ [project, store],
+ );
+
+ /**
+ * Der Schalter.
+ *
+ * Gegen eine Sperre tut er nichts, und zwar sichtbar nichts: die Praeferenz
+ * wird dann nicht einmal geschrieben. Eine gespeicherte Praeferenz, die
+ * niemals wirkt, waere ein Wert, der beim naechsten Projekt ohne Policy
+ * ploetzlich etwas einschaltet, um das an dieser Stelle niemand gebeten hat.
+ */
+ const toggleLlm = useCallback(() => {
+ setBrowserAiOpen(open => !open);
+ }, []);
+
+ // ------------------------------------------- Der Atlas-Chat (W5b) ------
+
+ /**
+ * Was eine Aufzeichnung auf dem Weg in ein Symbol gesehen hat.
+ *
+ * Eine Anfrage je Aufrufer an /api/trace, und keine fuenfte: die Route
+ * haengt `observed` an einen Hop einer Antwort, die aus dem STATISCHEN
+ * Graphen gerechnet ist (siehe src/traces/bug-paths.ts). Es gibt also keine
+ * Liste beobachteter Aufrufe, die man abfragen koennte, sondern nur die
+ * Frage "was wurde auf diesem Weg gesehen", und genau die wird hier
+ * gestellt. Ohne importierte Aufzeichnung kommt nichts zurueck, und das
+ * Rezept schreibt das als Notiz unter die Antwort statt es zu verschweigen.
+ */
+ const observedInto = useCallback(
+ async (subject: SymbolRef, callers: readonly { qualifiedName?: string; name: string }[]) => {
+ const to = subject.qualifiedName;
+ if (to === undefined || project.length === 0) {
+ return [] as ObservedFact[];
+ }
+ const found: ObservedFact[] = [];
+ for (const caller of callers) {
+ const from = caller.qualifiedName;
+ if (from === undefined) {
+ continue;
+ }
+ const answer = await api.trace(project, from, to).catch(() => undefined);
+ if (answer === undefined || !answer.reachable) {
+ continue;
+ }
+ answer.path.forEach((node, index) => {
+ const observed = node.observed;
+ const previous = answer.path[index - 1];
+ if (observed !== undefined && previous !== undefined) {
+ found.push({
+ from: previous.name,
+ to: node.name,
+ count: observed.count,
+ lastSeen: observed.lastSeen,
+ });
+ }
+ });
+ }
+ return found;
+ },
+ [api, project],
+ );
+
+ /** Eine Karte oeffnen: derselbe Weg, den jeder andere Klick dieser Oberflaeche geht. */
+ const openCardSource = useCallback(
+ (source: CardSource) => {
+ const target = twinTargetOf({
+ name: source.name,
+ qualifiedName: source.qualifiedName,
+ kind: 'unknown',
+ filePath: source.filePath,
+ startLine: source.line,
+ });
+ if (target === undefined) {
+ return;
+ }
+ followRef.current(target);
+ },
+ [],
+ );
+
+ /**
+ * Eine Frage an den Atlas.
+ *
+ * Der Compiler steht vor dem optionalen Modellzug: auch wenn das Modell
+ * aus, gesperrt oder nicht gestartet ist, bleiben Karten und ihre Quellen
+ * lesbar. Der Zug nennt den echten Grund und schickt nichts an den Sidecar.
+ */
+ const askQuestion = useCallback(
+ (question: string, chosenSubject?: string) => {
+ chatTicket.current += 1;
+ const id = chatTicket.current;
+ const base: ChatTurn = {
+ id,
+ question,
+ status: 'compiling',
+ depth: chatDepth,
+ cards: [],
+ gaps: [],
+ sources: [],
+ answer: '',
+ message: '',
+ };
+ const put = (turn: ChatTurn): void => {
+ setChatTurns((current) => current.map((entry) => (entry.id === id ? turn : entry)));
+ };
+ setChatTurns((current) => [...current, base]);
+ // Wer fragt, hat angefangen. Siehe AC6f und `dismissWhy`.
+ setWhyReopened(false);
+ setWhyDismissed(true);
+ // Eine Antwort hinter einer eingeklappten Flaeche waere genau die
+ // Stille, gegen die dieses Panel gebaut ist. Seit W8 heisst das: der
+ // Erklaeren-Bereich geht auf, und zwar auf DEM Reiter, der die
+ // Antwort traegt, statt einen zweiten Kasten aufzumachen.
+ openExplain('chat');
+
+ void askAtlas({
+ question,
+ source: provider,
+ root: ATLAS_WORKSPACE_ROOT,
+ origin: SIDECAR_ORIGIN,
+ fetch: (url, init) => window.fetch(url, init),
+ modelName: llmModel,
+ modelClass: modelClassOf(llmFacts?.contextTokens),
+ /*
+ * Die Modellwahl des Lesers, wenn sie wirkt. Siehe
+ * `requestModel`: ohne Router bleibt sie weg, und der Rumpf der
+ * Anfrage ist dann derselbe wie vor W10.
+ */
+ depth: chatDepth,
+ context: {
+ focusName: twinSymbol?.name,
+ focusQualifiedName: twinSymbol?.qualifiedName,
+ mode: bugOpen ? 'bug' : entryOpen ? 'entry'
+ : tour !== undefined ? 'tour' : 'none',
+ },
+ ...(twinSymbol === undefined ? {} : { focus: twinSymbol }),
+ ...(chosenSubject === undefined ? {} : { chosenSubject }),
+ observed: observedInto,
+ opts: { projectName: project, generation: 1 },
+ /* The normal turn always stops at the built card path. The
+ * model is an explicit choice in the rendered answer. */
+ useModel: false,
+ ...(llmState === 'ready' ? {} : {
+ modelUnavailableReason: llmState === 'disabled-by-policy'
+ ? 'policy'
+ : llmState === 'not-running' ? 'not-running' : 'off',
+ }),
+ }, id).then(put);
+ },
+ [
+ bugOpen, chatDepth, entryOpen, llmFacts, llmMode, llmModel, llmState,
+ observedInto, project, provider, tour, twinSymbol,
+ ],
+ );
+
+ /**
+ * Der Leser hat aus einer Kandidatenliste gewaehlt.
+ *
+ * Dieselbe Frage noch einmal, mit dem qualifizierten Namen daneben. Ein
+ * NEUER Zug und keine Aenderung am alten: der Zug mit der Liste bleibt
+ * stehen, damit sichtbar bleibt, dass es die Wahl gab und wie sie ausging.
+ * Ein Zug, der sich unter der Hand in eine Antwort verwandelt, waere ein
+ * Verlauf, dem man nicht ansieht, was passiert ist.
+ */
+ const pickCandidate = useCallback(
+ (turn: ChatTurn, candidate: SubjectCandidate) => {
+ askQuestion(turn.question, candidate.qualifiedName ?? candidate.name);
+ },
+ [askQuestion],
+ );
+
+ /*
+ * Der Layout-Griff, bei jedem Bild neu geschrieben.
+ *
+ * Ohne Abhaengigkeitsliste, wie die anderen Naehte dieser Datei: eine Liste
+ * waere eine zweite Stelle, an der jemand vergisst, ein Feld nachzutragen,
+ * und ein Griff, der eine Lage von vorhin meldet, ist schlimmer als keiner.
+ */
+ useEffect(() => {
+ globalThis.__atlasLayout = {
+ sizes: { ...shownZones },
+ requested: { ...zones },
+ bounds: Object.fromEntries(
+ LAYOUT_KEYS.map((key) => [key, layoutBounds(key, frame)]),
+ ) as Record,
+ defaults: defaultLayout(frame),
+ isDefault: sameLayout(shownZones, defaultLayout(frame)),
+ frame: { ...frame },
+ explainOpen,
+ explainTab,
+ tabs: explainTabsRef.current.map((tab) => ({
+ id: tab.id,
+ enabled: tab.enabled,
+ reason: tab.reason,
+ note: tab.note,
+ })),
+ storageKey: layoutStorageKey(project),
+ state: {
+ chatTurns: chatTurns.length,
+ walkStep: tour === undefined ? -1 : tourStep,
+ walkSteps: tour?.document.steps.length ?? 0,
+ flowStep,
+ flowSteps: flowView?.steps.length ?? 0,
+ },
+ };
+ });
+
+ useEffect(() => {
+ globalThis.__atlasChat = {
+ turns: chatTurns.map((turn) => ({
+ id: turn.id,
+ question: turn.question,
+ status: turn.status,
+ klass: turn.klass ?? '',
+ rule: turn.rule ?? '',
+ depth: turn.depth,
+ cards: turn.cards.length,
+ cardIds: turn.cards.map((card) => card.id),
+ sources: turn.sources.length,
+ citations: turn.check?.cited ?? [],
+ unknownCitations: turn.check?.unknown ?? [],
+ uncitedLines: (turn.check?.violations ?? [])
+ .filter((entry) => entry.reason === 'no-citation').length,
+ answer: turn.answer,
+ refusal: turn.refusal ?? '',
+ tokens: turn.tokens ?? 0,
+ budget: turn.budget ?? 0,
+ tokensPerSecond: turn.tokensPerSecond ?? 0,
+ candidates: (turn.choice?.candidates ?? []).map((candidate) => ({
+ name: candidate.name,
+ qualifiedName: candidate.qualifiedName ?? '',
+ filePath: candidate.filePath ?? '',
+ line: candidate.line ?? 0,
+ })),
+ askedName: turn.choice?.name ?? turn.focusFallback?.asked ?? '',
+ focusFallbackUsed: turn.focusFallback?.used ?? '',
+ })),
+ open: chatOpen,
+ height: shownZones.explainHeight,
+ maxHeight: layoutBounds('explainHeight', frame).max,
+ depth: chatDepth,
+ depthDefault: NEIGHBOR_DEPTH_DEFAULT,
+ depthOptions: [...NEIGHBOR_DEPTHS],
+ refineAvailable: llmState === 'ready',
+ refineState,
+ refineMessage,
+ // Der echte Validator, an derselben Stelle, an der ihn der Knopf
+ // ruft. Ein Beweislauf, der eine kaputte Antwort nachbaut, prueft
+ // sonst eine Kopie der Regel statt der Regel.
+ validateRefine: (answer: string) => {
+ if (pseudocode === undefined) {
+ return { applied: false, reason: 'no block is shown' };
+ }
+ const outcome = applyRefinement(pseudocode, answer);
+ return outcome.kind === 'applied'
+ ? { applied: true, reason: '' }
+ : { applied: false, reason: outcome.reason };
+ },
+ };
+ });
+
+ useEffect(() => {
+ globalThis.__atlasLlm = {
+ state: llmState,
+ preferenceOn: llmPreferenceOn,
+ policyVerdict: llmPolicy?.verdict ?? '',
+ policyPath: llmPolicy?.path ?? '',
+ policyDetail: llmPolicy?.detail ?? '',
+ model: llmModel,
+ chip: llmChipValue(llmState, llmModel),
+ probes: llmProbes.current,
+ };
+ });
+
+ /*
+ * Der Griff des Einstellungen-Panels, bei jedem Bild neu.
+ *
+ * Er steht HIER und nicht im Panel, obwohl das Panel die Messungen macht:
+ * die Wahl, die Anzeige-Einstellungen und die Modell-Liste gehoeren der
+ * Anwendung und ueberleben das Zuklappen. Ein Griff, den nur ein offenes
+ * Panel schreibt, waere nach dem Schliessen eine Beschreibung von vorhin,
+ * und `llmOffMakesNoRequests` muss auch bei geschlossenem Panel lesbar sein.
+ */
+ useEffect(() => {
+ globalThis.__atlasSettings = {
+ open: settingsOpen,
+ llmOn: llmMode === 'on',
+ probes: llmProbes.current,
+ router: llmRouter,
+ selectedModel,
+ cacheModels: llmModels.map((model) => ({
+ id: model.id,
+ name: model.name,
+ loaded: model.loaded,
+ status: model.status,
+ active: model.id === selectedModel
+ || (selectedModel.length === 0 && model.name === llmModel),
+ })),
+ running: {
+ model: llmModel,
+ modelPath: llmFacts?.modelPath ?? '',
+ quantization: llmFacts?.quantization ?? '',
+ contextTokens: llmFacts?.contextTokens ?? 0,
+ trainedContextTokens: llmFacts?.trainedContextTokens ?? 0,
+ weightsBytes: llmFacts?.weightsBytes ?? 0,
+ parameters: llmFacts?.parameters ?? 0,
+ },
+ suggestions: MODEL_SUGGESTIONS.map((entry) => ({
+ id: entry.id,
+ name: entry.name,
+ repo: entry.repo,
+ modelClass: entry.modelClass,
+ passRate: entry.passRate,
+ citationCompliance: entry.citationCompliance,
+ citationUnmeasured: entry.citationUnmeasured ?? null,
+ tokensPerSecond: entry.tokensPerSecond,
+ bytes: entry.bytes,
+ command: fetchCommand(entry.repo, entry.quant),
+ })),
+ display: { ...display },
+ displayDefault: { ...DEFAULT_GRAPH_DISPLAY },
+ isDefaultDisplay: isDefaultDisplay(display),
+ storageKeys: { model: modelKey(project), display: displayKey(project) },
+ measurements: Object.values(measurements),
+ };
+ });
+
+ // ------------------------------------------------------- Tastatur ------
+
+ const overlayOpen = isSearchable(command, SEARCH_MIN_QUERY);
+ /**
+ * Ob genau ein Zeichen dasteht: zu wenig zum Suchen, genug fuer eine
+ * Antwort auf die Frage "warum passiert nichts".
+ */
+ const oneCharTyped = command.trim().length > 0 && !overlayOpen;
+ const hitRows = useMemo(() => searchRows(hits, MAX_SEARCH_ROWS, hitSource), [hits, hitSource]);
+
+ /*
+ * Der Griff der Suche und der Menuezeile, bei jedem Bild neu geschrieben.
+ *
+ * Bei jedem Bild und nicht nur bei einer Aenderung: die Zaehler stehen in
+ * Refs, also weiss React nichts von ihnen, und ein Effekt mit
+ * Abhaengigkeitsliste wuerde sie nur zufaellig mitnehmen.
+ */
+ useEffect(() => {
+ globalThis.__atlasSearch = {
+ debounceMs: SEARCH_DEBOUNCE_MS,
+ minQuery: SEARCH_MIN_QUERY,
+ currentQuery: command.trim(),
+ shownQuery: answeredQuery,
+ shownSource: hitSource,
+ shownRows: hitRows.length,
+ localFirst: searchStats.current.localFirst,
+ prefixCacheHits: searchStats.current.prefixCacheHits,
+ serverRequests: searchStats.current.serverRequests,
+ serverRoundtripMs: searchStats.current.serverRoundtripMs,
+ roundtrips: [...searchStats.current.roundtrips],
+ abortedRequests: searchStats.current.abortedRequests,
+ staleDropped: searchStats.current.staleDropped,
+ staleAnswerWins: searchStats.current.staleAnswerWins,
+ localCandidates: localPool.length,
+ activatedMenus: [...activatedMenus.current],
+ keyListenerCapture: KEY_LISTENER_OPTIONS.capture,
+ };
+ });
+
+ /**
+ * Die Tasten des Suchfensters.
+ *
+ * Nur solange es offen ist, und nur die vier, die ihm gehoeren. Ein
+ * verbrauchtes Ereignis wird als verbraucht markiert, damit das Chrome
+ * Escape nicht ein zweites Mal liest und das Feld den Fokus verliert,
+ * waehrend der Leser noch tippt.
+ */
+ /**
+ * Was Enter in der Kommandozeile bedeutet.
+ *
+ * Die Entscheidung selbst steht in src/chat/command-intent.ts, samt
+ * Begruendung: die Suche behaelt den Vorrang, und eine Zeile mit
+ * Fragezeichen, eine Zeile mit @ und eine Zeile, auf die die Suche mit
+ * nichts geantwortet hat, sind Fragen. Hier steht nur, was mit der
+ * Entscheidung passiert.
+ */
+ const enterIntent = useMemo(
+ () => commandIntent({
+ line: command,
+ hitCount: hitRows.length,
+ answered: answeredQuery === command.trim() && searchStatus === 'ready',
+ }),
+ [answeredQuery, command, hitRows.length, searchStatus],
+ );
+
+ const onCommandKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ /*
+ * Der eine Befehl steht VOR der Suche und vor der Frage.
+ *
+ * Nicht, weil er wichtiger waere, sondern weil er sonst nie
+ * ankaeme: "reset layout" findet kein Symbol, also waere die Zeile
+ * eine Frage an das Modell, und der Leser bekaeme eine Antwort
+ * darueber, dass es kein Symbol dieses Namens gibt. Die Erkennung
+ * ist absichtlich eng (src/layout/layout-command.ts); jede
+ * Unschaerfe hier waere eine Suche, die stattdessen das Layout
+ * umbaut.
+ */
+ if (event.key === 'Enter') {
+ const line = lineCommandOf(command);
+ if (line === 'reset-layout') {
+ event.preventDefault();
+ resetLayout();
+ setCommand('');
+ closeSearch();
+ return;
+ }
+ if (line === 'open-settings') {
+ event.preventDefault();
+ setSettingsOpen(true);
+ setCommand('');
+ closeSearch();
+ return;
+ }
+ if (experimentalAgentsEnabled && line === 'toggle-live-agents') {
+ event.preventDefault();
+ setLiveAgentsOn((on) => !on);
+ setCommand('');
+ closeSearch();
+ return;
+ }
+ /*
+ * "fullscreen" (W11b). Der Modus ist der Rahmen der
+ * Agenten-Ansicht; ist der Live-Modus aus, geht er mit an,
+ * statt dass die Zeile still nichts tut.
+ */
+ if (experimentalAgentsEnabled && line === 'toggle-fullscreen') {
+ event.preventDefault();
+ setLiveAgentsOn(true);
+ setFullscreenToggle((count) => count + 1);
+ setCommand('');
+ closeSearch();
+ return;
+ }
+ }
+ // Enter gilt auch dann, wenn kein Suchfenster offen ist: eine Frage
+ // von zwei Zeichen Laenge oeffnet keines, und sie muss trotzdem
+ // abschickbar sein.
+ if (event.key === 'Enter' && enterIntent === 'ask') {
+ event.preventDefault();
+ const question = command.trim();
+ closeSearch();
+ askQuestion(question);
+ return;
+ }
+ if (!overlayOpen) {
+ return;
+ }
+ const intent = overlayIntent(event.key);
+ if (intent === 'none') {
+ return;
+ }
+ event.preventDefault();
+ switch (intent) {
+ case 'up':
+ setSelectedHit((current) => moveSelection(hitRows.length, current, -1));
+ return;
+ case 'down':
+ setSelectedHit((current) => moveSelection(hitRows.length, current, 1));
+ return;
+ case 'close':
+ closeSearch();
+ return;
+ case 'choose':
+ chooseHit(selectedHit);
+ return;
+ default:
+ return;
+ }
+ },
+ [
+ askQuestion, chooseHit, closeSearch, command, enterIntent, hitRows.length, overlayOpen,
+ resetLayout, selectedHit,
+ ],
+ );
+
+ /*
+ * Der eine Griff am Fenster, der entscheidet, wer eine Taste bekommt.
+ *
+ * Er liegt am Fenster und nicht am Menue oder an der Zeile: beides soll
+ * gelten, waehrend der Fokus im Baum, auf einem Knopf oder nirgends steht.
+ * WANN eine Taste was bedeutet, entscheidet src/app/keyboard.ts (dort steht
+ * auch, warum die Kuerzel seit dem 2026-08-29 Alt/Option tragen); WAS ein
+ * Kuerzel tut, steht in `menuActionsRef`, das weiter unten bei den
+ * Menuepunkten geschrieben wird.
+ *
+ * Die Reihenfolge ist die ganze Aussage, und sie steht hier einmal:
+ *
+ * 1. **Ein Menuekuerzel zuerst.** Alt/Option + Buchstabe, oder die
+ * reservierte Hilfetaste.
+ * 2. **Eine Flaeche, die die Tastatur fuehrt, behaelt sie.** Das sind seit
+ * W8 noch zwei, die Hilfe und der Einstiegsdialog (siehe
+ * `keyboardGuardRef`); hinter ihnen faellt kein Zeichen in eine Zeile,
+ * die der Leser gerade nicht ansieht.
+ * 3. **Einer laufenden Fuehrung gehoeren ihre vier Tasten.** Welche das
+ * sind, sagt `playerIntent` und nicht eine zweite Liste hier.
+ * 4. **Alles andere Druckbare gehoert der Kommandozeile.** Sie holt sich
+ * den Fokus und bekommt das Zeichen, damit Tippen sichtbar ankommt,
+ * statt im Hintergrund Panels zu oeffnen (Nutzerbefund 2026-08-29).
+ *
+ * Seit W7b haengt der Griff in der EINFANGENDEN Phase. Der Grund steht bei
+ * KEY_LISTENER_OPTIONS in src/app/keyboard.ts: der Nutzerbefund "alt plus
+ * letter funktioniert nur fuer atlas" liess sich nicht nachstellen, also
+ * wird nicht an einer Ursache geraten, sondern die eine Klasse von Ursachen
+ * ausgeschlossen, die diese Anwendung selbst in der Hand hat, naemlich ein
+ * Griff auf dem Weg nach oben, der die Taste vorher verbraucht. In der
+ * einfangenden Phase sieht das Fenster den Druck als erster, und niemand
+ * kann ihm zuvorkommen.
+ *
+ * Ein verbrauchtes Ereignis wird zusaetzlich gezeichnet (`markHandled`).
+ * Das ist nicht fuer die Verdrahtung, sondern fuer den Tastentest der
+ * Hilfeseite: `defaultPrevented` allein kann nicht sagen, ob DIESER Griff
+ * die Taste genommen hat oder jemand vor ihm, und genau das ist die Frage,
+ * die der Nutzerbefund stellt.
+ */
+ useEffect(() => {
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ const target = event.target as Element | null;
+ const shortcut = menuShortcutFor(event, target, WIRED_MENU_SHORTCUTS);
+ if (shortcut !== undefined) {
+ const act = menuActionsRef.current[shortcut];
+ if (act !== undefined) {
+ event.preventDefault();
+ markHandled(event, shortcut);
+ act();
+ }
+ return;
+ }
+ const guard = keyboardGuardRef.current;
+ if (guard.overlayOpen) {
+ return;
+ }
+ if (guard.walkRunning && playerIntent(event.key) !== 'none') {
+ return;
+ }
+ };
+ window.addEventListener('keydown', onKeyDown, KEY_LISTENER_OPTIONS);
+ return () => window.removeEventListener('keydown', onKeyDown, KEY_LISTENER_OPTIONS);
+ }, []);
+
+ /*
+ * Escape schliesst die Hilfe, auch von ausserhalb der Hilfe.
+ *
+ * Die Seite faengt Escape schon selbst ab (sie holt sich den Fokus beim
+ * Aufschlagen). Dieser Griff ist fuer den Fall danach: wer im Baum oder in
+ * der Kommandozeile geklickt hat, waehrend die Hilfe offen steht, drueckt
+ * Escape und meint trotzdem die Hilfe. Er haengt nur, solange sie offen ist,
+ * damit Escape sonst genau so wenig bedeutet wie vorher.
+ */
+ useEffect(() => {
+ if (!helpOpen) {
+ return;
+ }
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return;
+ }
+ event.preventDefault();
+ setHelpOpen(false);
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [helpOpen]);
+
+ // Dasselbe fuer das Einstellungen-Panel, und aus demselben Grund: wer im
+ // Baum oder in der Kommandozeile geklickt hat, waehrend es offen steht,
+ // drueckt Escape und meint trotzdem das Panel.
+ useEffect(() => {
+ if (!settingsOpen) {
+ return;
+ }
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return;
+ }
+ event.preventDefault();
+ setSettingsOpen(false);
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [settingsOpen]);
+
+ // And for the projects panel, which has input fields of its own as well.
+ useEffect(() => {
+ if (!projectsOpen) {
+ return;
+ }
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return;
+ }
+ event.preventDefault();
+ setProjectsOpen(false);
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [projectsOpen]);
+
+ /**
+ * Die vier Tasten einer laufenden Fuehrung.
+ *
+ * Am Fenster und nicht an der Karte, weil der Fokus waehrend einer Fuehrung
+ * fast immer im Editor steht: waeren die Tasten an der Karte, muesste der
+ * Leser vor jedem Schritt erst dorthin klicken. Wo sie NICHT gelten, sagt
+ * `tourKeyForEvent`; in Kuerze: in einem echten Eingabefeld.
+ *
+ * `d` ist die vierte und bewegt die Fuehrung nicht: sie schlaegt das Bild
+ * zum Schritt auf. Es gibt es nur, wenn der Schritt ein Symbol hat, und
+ * ohne Symbol passiert nichts und die Taste bleibt unverbraucht, damit ein
+ * gedrueckte `d` nicht so aussieht, als haette es etwas getan.
+ */
+ useEffect(() => {
+ const steps = tour?.document.steps.length ?? 0;
+ if (steps === 0) {
+ return;
+ }
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ const key = tourKeyForEvent(event, event.target as Element | null);
+ if (key === undefined) {
+ return;
+ }
+ const intent = playerIntent(key);
+ if (intent === 'none') {
+ return;
+ }
+ if (intent === 'diagram') {
+ const step = tour?.document.steps[tourStepRef.current];
+ if (step?.primary.kind !== 'symbol') {
+ return;
+ }
+ event.preventDefault();
+ openExplain('flow');
+ return;
+ }
+ event.preventDefault();
+ if (intent === 'exit') {
+ closeTour();
+ return;
+ }
+ if (intent === 'prev') {
+ setTourStep(stepMove(steps, tourStepRef.current, -1));
+ return;
+ }
+ if (isLastStep(tourStepRef.current, steps)) {
+ closeTour();
+ return;
+ }
+ setTourStep(stepMove(steps, tourStepRef.current, 1));
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [closeTour, openExplain, tour]);
+
+ // Escape schliesst den Einstiegsdialog, auch aus seinem eigenen Suchfeld
+ // heraus: dort ist Escape sonst nichts, und ein Dialog ohne Ausgang ueber
+ // die Tastatur ist ein Dialog, aus dem man nur mit der Maus herauskommt.
+ useEffect(() => {
+ if (!entryOpen) {
+ return;
+ }
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ if (event.key !== 'Escape') {
+ return;
+ }
+ event.preventDefault();
+ setEntryOpen(false);
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [entryOpen]);
+
+ /*
+ * Escape schliesst die Frage nach dem Warum.
+ *
+ * Sie stand bis W8b NICHT im Escape-Griff dieser Datei: dort standen Hilfe,
+ * Flow, Einstieg, Assistent, Aenderungsansicht und Suchfenster, und die
+ * einzige Flaeche, die ueber dem Editor liegen und dort bleiben konnte,
+ * hatte keinen Ausgang ueber die Tastatur (Nutzerbefund 2026-08-29, AC6f).
+ * Der einzige Ausweg war ein Knopf, der "Not now" hiess.
+ *
+ * Die Stelle in der Reihenfolge ist die richtige und keine beliebige: die
+ * Hilfe und der Einstiegsdialog liegen UEBER der Frage, das Suchfenster
+ * gehoert der Kommandozeile, und der Erklaeren-Bereich liegt darunter. Der
+ * Griff nimmt die Taste also nach den drei ersten und vor dem letzten, und
+ * er markiert sie als verbraucht, damit der Bereich sie liegenlaesst.
+ */
+ useEffect(() => {
+ if (!whyVisible) {
+ return;
+ }
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return;
+ }
+ if (helpOpen || entryOpen || overlayOpen) {
+ return;
+ }
+ event.preventDefault();
+ dismissWhy();
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [dismissWhy, entryOpen, helpOpen, overlayOpen, whyVisible]);
+
+ /*
+ * Escape klappt den Erklaeren-Bereich ein, und zwar zuletzt.
+ *
+ * Ein Griff fuer alle fuenf Reiter, wo bis W8 drei Griffe standen (Chat,
+ * Flow-Overlay, und die Sammelklausel fuer Assistent und Aenderungsansicht).
+ * Was dabei gilt, ist genau die Zusage aus W7c, nur breiter: Escape kostet
+ * NICHTS. Kein Zug wird geloescht, kein Schritt zurueckgesetzt, keine
+ * Fuehrung beendet; der Streifen darunter sagt weiter, was noch dasteht, und
+ * ein Klick auf den Reiter bringt alles zurueck. Der Nutzerbefund vom
+ * 2026-08-29, aus dem diese Trennung entstanden ist, gilt damit fuer jede
+ * der fuenf Flaechen statt nur fuer den Chat.
+ *
+ * Der Griff nimmt die Taste nur, wenn sie sonst niemand braucht, und das ist
+ * keine Hoeflichkeit, sondern die Reihenfolge: die Hilfe und der
+ * Einstiegsdialog liegen ueber dem Editor, das Suchfenster steht ueber der
+ * Kommandozeile, und eine Zone weiter unten, die ihnen Escape wegnaehme,
+ * waere eine Zone, die eine offene Flaeche stehenlaesst. Dazu das Feld unter
+ * dem Zeiger: wer in der Kommandozeile steht, verlaesst mit Escape die
+ * Zeile, und dieser Griff hat dort nichts zu suchen.
+ */
+ useEffect(() => {
+ if (!explainOpen) {
+ return;
+ }
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return;
+ }
+ if (helpOpen || entryOpen || overlayOpen) {
+ return;
+ }
+ /*
+ * Genau EIN Feld behaelt Escape fuer sich, und es ist die
+ * Kommandozeile: wer dort steht, verlaesst mit Escape die Zeile.
+ * Nicht `isTypingTarget`, obwohl der Chat-Griff aus W7c das so
+ * hatte: der Editor faellt darunter, und der Flow-Erklaerer schickt
+ * bei jedem Schritt den Caret dorthin. Mit der weiten Pruefung waere
+ * Escape genau dann taub, wenn der Leser gerade Schritte gegangen
+ * ist, und das war bis W8 der Fall, den das Overlay mit einem
+ * eigenen Griff ohne diese Pruefung abgedeckt hat.
+ */
+ if (event.target === commandInputRef.current) {
+ return;
+ }
+ event.preventDefault();
+ collapseExplain();
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [collapseExplain, entryOpen, explainOpen, helpOpen, overlayOpen]);
+
+ /**
+ * Der Griff, an dem der Beweislauf die Fuehrung anfasst.
+ *
+ * `regenerate` ist der Determinismus-Beweis und steht deshalb hier und nicht
+ * im Lauf: der Lauf soll dieselbe Erzeugung noch einmal ausloesen, die die
+ * Anwendung benutzt, und nicht eine nachgebaute daneben. Angeboten wird sie
+ * nur fuer die Projekt-Fuehrung; ein Vorwaerts-Walk haengt an einer Wahl und
+ * ist nicht dieselbe Frage zweimal.
+ */
+ useEffect(() => {
+ globalThis.__atlasTour =
+ tour === undefined
+ ? undefined
+ : {
+ id: tour.document.id,
+ kind: tour.kind,
+ title: tour.document.title,
+ steps: tour.document.steps.length,
+ index: tourStep,
+ paths: tour.document.steps.map((step) => step.primary.filePath),
+ titles: tour.document.steps.map((step) => step.title),
+ endNote: tour.endNote,
+ json: JSON.stringify(tour.document),
+ regenerate:
+ tour.kind === 'project'
+ ? async () =>
+ JSON.stringify(
+ (
+ await generateProjectTour(provider, ATLAS_WORKSPACE_ROOT, {
+ projectName: project,
+ generation: 1,
+ })
+ ).document,
+ )
+ : undefined,
+ };
+ });
+
+ const onTreeKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ const row = rows[cursor];
+ const intent = treeIntent(event.key, row);
+ if (intent === 'none') {
+ return;
+ }
+ event.preventDefault();
+ switch (intent) {
+ case 'down':
+ setCursor(moveCursor(rows.length, cursor, 1));
+ return;
+ case 'up':
+ setCursor(moveCursor(rows.length, cursor, -1));
+ return;
+ case 'open':
+ if (row !== undefined) {
+ openFile(row.path);
+ }
+ return;
+ case 'expand':
+ case 'collapse':
+ if (row !== undefined) {
+ toggleDirectory(row);
+ }
+ return;
+ case 'toParent': {
+ if (row === undefined) {
+ return;
+ }
+ const parent = parentPath(row.path);
+ const index = rows.findIndex((candidate) => candidate.path === parent);
+ if (index >= 0) {
+ setCursor(index);
+ }
+ return;
+ }
+ default:
+ return;
+ }
+ },
+ [cursor, openFile, rows, toggleDirectory],
+ );
+
+ // ------------------------------------------------------- Anzeige -------
+
+ /**
+ * Die stille Anzeige fuer das Symbol vor dem Leser.
+ *
+ * Undefiniert, solange es keine Checkliste gibt, und die Statusleiste laesst
+ * den Punkt dann ganz weg. "explored 0 of 0" waere ein Befund ueber das
+ * Symbol, wo in Wahrheit noch niemand gefragt hat.
+ */
+ const understanding = useMemo(() => {
+ const owner = twinIr?.symbol.qualifiedName;
+ return owner === undefined || twinIr === undefined
+ ? undefined
+ : understandingOf(marks, owner, twinIr.checklist);
+ }, [marks, twinIr]);
+ const explored = exploredLabel(understanding);
+
+ useEffect(() => {
+ globalThis.__atlasChecklist = {
+ symbol: understanding?.symbolQualifiedName ?? '',
+ explored: understanding?.exploration.visited ?? 0,
+ total: understanding?.exploration.total ?? 0,
+ label: explored ?? '',
+ marks: totalMarks(marks),
+ };
+ });
+
+ const rootLevel = levels.get('');
+ const treeNoteParts: string[] = [];
+ if (treeError.length > 0) {
+ treeNoteParts.push(`tree unavailable: ${treeError}`);
+ } else if (rootLevel !== undefined) {
+ treeNoteParts.push(`${rootLevel.files} files, ${rootLevel.symbols} symbols from ${TREE_ROUTE}`);
+ const dropped = [...levels.values()].reduce((sum, level) => sum + level.droppedNonPaths, 0);
+ if (dropped > 0) {
+ treeNoteParts.push(
+ dropped === 1
+ ? '1 index entry names no path and is not shown'
+ : `${dropped} index entries name no path and are not shown`,
+ );
+ }
+ if (budgetHit) {
+ treeNoteParts.push(`only ${EAGER_LEVEL_BUDGET} folders opened up front, the rest load on click`);
+ }
+ } else if (project.length === 0) {
+ treeNoteParts.push(projectDetail.length > 0 ? projectDetail : 'no project');
+ } else {
+ treeNoteParts.push('loading the tree ...');
+ }
+ // Die zweite Quelle bekommt ihren eigenen Satz. "Noch nicht gefragt" und
+ // "gefragt, nichts gefunden" sehen im Baum gleich aus und sind es nicht.
+ if (coverageError.length > 0) {
+ treeNoteParts.push(`coverage unavailable: ${coverageError}`);
+ } else if (!coverageAsked) {
+ treeNoteParts.push('joining the coverage lists ...');
+ } else {
+ treeNoteParts.push(
+ `${coverage.counts.partial} partial, ${coverage.counts.skipped} skipped, `
+ + `${coverage.counts.notIndexedFiles} files and ${coverage.counts.notIndexedDirs} folders `
+ + `not indexed, ${coverage.counts.scopeEntries} coverage entries joined in`,
+ );
+ }
+
+ const chips: Chip[] = [
+ {
+ label: messages.statusbar.chipProject,
+ value: project.length > 0 ? project : messages.app.noProject,
+ state: project.length > 0 ? 'plain' : 'absent',
+ },
+ {
+ label: messages.statusbar.chipSymbols,
+ value: counts.nodes === undefined ? messages.statusbar.noCount : String(counts.nodes),
+ state: counts.nodes === undefined ? 'absent' : 'plain',
+ },
+ {
+ label: messages.statusbar.chipEdges,
+ value: counts.edges === undefined ? messages.statusbar.noCount : String(counts.edges),
+ state: counts.edges === undefined ? 'absent' : 'plain',
+ },
+ ];
+
+ const status: Chip[] = [
+ {
+ label: messages.statusbar.chipProject,
+ value: project.length > 0 ? project : messages.app.noProject,
+ state: project.length > 0 ? 'ok' : 'absent',
+ },
+ {
+ label: messages.statusbar.chipServer,
+ value: serverOk === undefined
+ ? messages.statusbar.serverContacting
+ : serverOk ? messages.statusbar.serverReady : messages.statusbar.serverUnreachable,
+ state: serverOk === true ? 'ok' : serverOk === false ? 'absent' : 'plain',
+ },
+ {
+ label: messages.statusbar.chipPort,
+ value: window.location.port.length > 0 ? window.location.port : '80',
+ },
+ { label: messages.statusbar.chipTree, value: TREE_ROUTE },
+ {
+ label: messages.statusbar.chipSource,
+ value: document === undefined ? messages.statusbar.noFileLoaded : READER_RPC_TOOL,
+ state: document === undefined ? 'absent' : 'ok',
+ },
+ {
+ label: messages.statusbar.chipGalaxy,
+ value:
+ layout === undefined
+ ? messages.galaxy.noLayout
+ : messages.galaxy.nodeCount(layout.nodes.length, !galaxyOn),
+ state: layout === undefined ? 'absent' : galaxyOn ? 'ok' : 'plain',
+ },
+ /*
+ * Der Chip des lokalen Modells. Er steht IMMER da, auch im Zustand aus,
+ * und das ist der Punkt: "es gibt hier ein lokales Modell und es ist
+ * aus" ist eine andere Aussage als ein Chip, der erst auftaucht, wenn
+ * etwas laeuft. Der zweite waere aus der Ferne nicht von "dieses Produkt
+ * kann das nicht" zu unterscheiden.
+ */
+ {
+ label: messages.statusbar.chipLlm,
+ value: llmChipValue(llmState, llmModel),
+ state: llmState === 'ready' ? 'ok' : llmState === 'starting' ? 'plain' : 'absent',
+ },
+ ];
+ /*
+ * Die Frische-Notiz der offenen Datei.
+ *
+ * Sie steht nur da, wenn der Server etwas zu sagen hat: `metadata_match`
+ * zusammen mit `no_recorded_issue` ist die Lage, in der die Datei seit dem
+ * Index unveraendert ist und keine Quelle etwas gemeldet hat. Ein Chip, der
+ * auch das anzeigt, waere eine Ampel, die immer leuchtet.
+ */
+ const coverageAnswer = openCoverage;
+ const freshnessLabel =
+ coverageAnswer !== undefined
+ && freshnessNoteNeeded(coverageAnswer.status, coverageAnswer.freshness)
+ ? freshnessNote(
+ coverageAnswer.status,
+ coverageAnswer.freshness,
+ coverageAnswer.recommendedAction,
+ )
+ : '';
+ if (freshnessLabel.length > 0) {
+ status.push({ label: messages.statusbar.chipCoverage, value: freshnessLabel, state: 'absent' });
+ }
+ // Die stille Anzeige steht nur da, wenn sie etwas zu zaehlen hat.
+ if (explored !== undefined) {
+ status.push({ label: messages.statusbar.chipExplored, value: explored, state: 'ok' });
+ }
+
+ /*
+ * Die Notiz ueber dem Editor.
+ *
+ * Nur fuer die eine Lage, in der der Text dasteht und trotzdem
+ * unvollstaendig gelesen wurde: eine partiell geparste Datei. Die Faelle
+ * ohne Inhalt sagen ihre Begruendung an der Stelle, an der sonst der Text
+ * stuende, und brauchen darum keine zweite Zeile darueber.
+ */
+ const openRecord = coverage.records.get(activePath);
+ const coverageNote =
+ readerStatus === 'ready' && openRecord?.state === 'partial'
+ ? { state: 'partially parsed', text: partialFileNote(activePath, openRecord.reason) }
+ : undefined;
+
+ useEffect(() => {
+ globalThis.__atlasCoverage = {
+ rows: rows.map((row) => ({
+ path: row.path,
+ kind: row.kind,
+ coverage: row.coverage ?? 'indexed',
+ reason: row.coverageReason ?? '',
+ sources: row.coverageSources ?? [],
+ })),
+ records: [...coverage.records.values()].map((record) => ({
+ path: record.path,
+ kind: record.kind,
+ state: record.state,
+ reason: record.reason,
+ sources: [...record.sources],
+ })),
+ truncations: [...coverage.truncations],
+ counts: { ...coverage.counts },
+ metadata: { ...coverageMeta },
+ open:
+ coverageAnswer === undefined
+ ? undefined
+ : {
+ path: coverageAnswer.path,
+ status: coverageAnswer.status,
+ freshness: coverageAnswer.freshness,
+ action: coverageAnswer.recommendedAction,
+ note: freshnessLabel,
+ },
+ error: coverageError,
+ };
+ });
+ if (tourMessage.length > 0) {
+ status.push({ label: messages.statusbar.chipWalk, value: tourMessage, state: 'absent' });
+ }
+
+ const tabDescriptors: TabDescriptor[] = tabs.map((path) => ({
+ path,
+ name: baseName(path),
+ active: path === activePath,
+ }));
+
+ // Badges nur, solange der Reader die Datei zeigt, in der das Symbol steht.
+ // Ohne diese Bedingung malte ein Dateiwechsel fuer einen Wimpernschlag die
+ // Aufrufstellen des vorigen Symbols in die neue Datei.
+ const twinPath = twinSymbol === undefined ? '' : workspacePathOf(twinSymbol.uri);
+ // Gemerkt, damit der Editor seine Dekorationen nur dann neu setzt, wenn sich
+ // die Schritte wirklich geaendert haben, und nicht bei jedem Bild.
+ const badges = useMemo(
+ () =>
+ twinIr !== undefined && twinPath === activePath
+ ? badgesForLines(
+ twinIr.steps.value.map((call) => ({ line: call.line, label: call.targetName })),
+ )
+ : noBadges,
+ [twinIr, twinPath, activePath],
+ );
+
+ /*
+ * Die vier Eintraege der Atlas-Zeile, EINMAL.
+ *
+ * Sie stehen als Liste da und nicht zweimal ausgeschrieben, weil sie seit
+ * dem 2026-08-29 zwei Leser haben: das Menue zeichnet sie, und die Tastatur
+ * ruft sie. Zwei Fassungen derselben Liste waeren zwei Gelegenheiten, ein
+ * Etikett zu aendern und das Kuerzel dabei zu vergessen, und ein Menuepunkt,
+ * dessen Buchstabe etwas anderes tut, als daneben steht, ist schlimmer als
+ * einer ohne Buchstaben.
+ *
+ * `key` ist das Suffix von `data-menu` (also `a-why`) und bleibt, was es
+ * war: die Beweislaeufe fassen die Eintraege daran an. `shortcut` ist der
+ * Buchstabe, und er steht im Etikett in Klammern, weil ein Terminal keine
+ * Unterstreichung hat.
+ */
+ const atlasEntries: readonly (MenuExtra & { shortcut: string })[] = [
+ {
+ key: 'why',
+ shortcut: 'w',
+ label: WHY_MENU_LABEL,
+ title: messages.menu.why,
+ onSelect: () => {
+ // Die Frage neu zu stellen heisst, von vorn anzufangen: die
+ // laufende Fuehrung endet, und der Erklaeren-Bereich klappt zu,
+ // damit die Frage die leere Editorflaeche bekommt.
+ closeTour();
+ setEntryOpen(false);
+ collapseExplain();
+ setWhyDismissed(false);
+ setWhyReopened(true);
+ },
+ },
+ {
+ key: 'bug',
+ shortcut: 'b',
+ label: BUG_WIZARD_MENU_LABEL,
+ title: messages.menu.bug,
+ onSelect: () => {
+ /*
+ * Kein `closeTour()` mehr, und das ist eine Aenderung mit Grund.
+ * Bis W8 deckte der Assistent den Editor ab und die Schrittkarte
+ * lag darunter, also musste die Fuehrung weichen. Jetzt teilen
+ * sich beide einen Platz und wechseln ueber einen Reiter; eine
+ * Fuehrung zu beenden, weil jemand kurz in den Assistenten
+ * schaut, waere genau der Zustandsverlust, den dieser Zyklus
+ * abschafft.
+ */
+ setEntryOpen(false);
+ setWhyReopened(false);
+ openExplain('bug');
+ },
+ },
+ /*
+ * Der Weg zurueck aus AC3, als Menuepunkt.
+ *
+ * Er steht in derselben Zeile wie die anderen vier und traegt wie sie
+ * einen Buchstaben: eine Handlung, die es nur in der Kommandozeile gibt,
+ * ist in einer Oberflaeche, deren Vorbild die Tastatur ist, eine halbe
+ * Handlung.
+ */
+ {
+ key: 'layout',
+ shortcut: 'r',
+ label: messages.layout.resetMenuLabel,
+ title: messages.layout.resetTooltip,
+ onSelect: resetLayout,
+ },
+ /*
+ * Der Schalter im Menue, mit demselben Ausgang wie der im Panel.
+ * Ein Extra traegt keinen Zustand, also traegt ihn sein Etikett:
+ * `[l]lm off` heisst "es ist aus" und nicht "hier ausschalten".
+ * Diese Lesart ist dieselbe wie bei [a]tlas, dessen `data-state`
+ * ebenfalls die Lage und nicht die Wirkung faerbt.
+ */
+ {
+ key: 'llm',
+ shortcut: 'l',
+ label: messages.browserChat.menuLabel,
+ title: messages.browserChat.menuTitle,
+ onSelect: toggleLlm,
+ },
+ /*
+ * Das Einstellungen-Panel (W10), als Eintrag der Atlas-Zeile.
+ *
+ * Es schaltet, wie [a]tlas und [?]help: der Buchstabe steht fuer die
+ * Flaeche, und ein zweiter Druck macht sie wieder zu. Ein Eintrag, der
+ * nur oeffnet, waere in dieser Zeile der einzige.
+ */
+ {
+ key: 'settings',
+ shortcut: 's',
+ label: messages.settings.menuLabel,
+ title: messages.menu.settings,
+ onSelect: () => setSettingsOpen((open) => !open),
+ },
+ /*
+ * The projects panel, as an entry of the atlas row. It toggles like
+ * [s]ettings next to it: the letter stands for the surface, and a
+ * second press closes it again.
+ */
+ {
+ key: 'projects',
+ shortcut: 'p',
+ label: messages.projects.addIndexLabel,
+ title: messages.projects.addIndexTitle,
+ onSelect: () => setProjectsOpen((open) => !open),
+ },
+ /*
+ * Der Live-Modus der Agenten (W11a), als Eintrag der Atlas-Zeile.
+ *
+ * Er schaltet, wie [l]ocal llm daneben, und sein Etikett traegt den
+ * ZUSTAND und nicht die Wirkung: "off" heisst "es ist aus". Es gibt ihn
+ * hier und in der Kommandozeile ("live agents"), weil ein Modus, der per
+ * Vorgabe aus ist, sonst nur denen gehoert, die das Kuerzel kennen.
+ */
+ ...(experimentalAgentsEnabled ? [{
+ key: 'agents',
+ shortcut: 'g',
+ label: agentsMenuLabel(liveAgentsOn),
+ title: liveAgentsOn ? messages.menu.agentsOn : messages.menu.agentsOff,
+ onSelect: () => setLiveAgentsOn((on) => !on),
+ }] : []),
+ ];
+
+ /*
+ * Was ein Buchstabe tut, in einem Ref und nicht in einer Abhaengigkeit.
+ *
+ * Der Griff am Fenster wird einmal eingehaengt (unten, mit leerer
+ * Abhaengigkeitsliste), weil ein Menuekuerzel keinen Grund hat, bei jedem
+ * Bild ab- und wieder anzumelden. Die Handlungen darin haengen aber an
+ * `llmState`, an `tour` und an drei Zustandssetzern; sie in die Schliessung
+ * des Effekts zu schreiben hiesse, dass der Buchstabe die Lage von vor
+ * hundert Bildern schaltet. Das Ref ist die Stelle, an der beides
+ * zusammengeht: bei jedem Bild neu geschrieben, vom Griff bei jedem
+ * Tastendruck frisch gelesen.
+ */
+ /**
+ * Eine Handlung, mit einem Vermerk davor.
+ *
+ * Der Vermerk steht dort, wo die Handlung LAEUFT, und nicht dort, wo
+ * geklickt wird: ein Beweislauf, der Klicks zaehlt, hat Klicks bewiesen. Er
+ * ist auch der Grund, aus dem Knopf und Taste dieselbe Funktion rufen statt
+ * zwei gleich aussehender: dass die Maus und die Tastatur dasselbe tun,
+ * soll keine Behauptung sein, sondern eine Eigenschaft der Verdrahtung.
+ */
+ const menuAct = (letter: string, act: () => void) => () => {
+ setWorkspace('explore');
+ activatedMenus.current = [...activatedMenus.current, letter];
+ act();
+ };
+
+ menuActionsRef.current = {
+ a: menuAct('a', () => setGalaxyOn((current) => !current)),
+ '?': menuAct('?', () => setHelpOpen((current) => !current)),
+ ...Object.fromEntries(
+ atlasEntries.map((entry) => [entry.shortcut, menuAct(entry.shortcut, entry.onSelect)]),
+ ),
+ };
+
+ const menus: Partial> = {
+ a: {
+ title: galaxyOn ? messages.menu.atlasHide : messages.menu.atlasShow,
+ state: galaxyOn ? 'on' : 'off',
+ onSelect: menuActionsRef.current['a'] ?? (() => undefined),
+ extras: atlasEntries.map((entry) => ({
+ ...entry,
+ onSelect: menuActionsRef.current[entry.shortcut] ?? entry.onSelect,
+ })),
+ },
+ /*
+ * `[?]help` schaltet, statt nur zu oeffnen, und zwar aus demselben
+ * Grund wie `[a]tlas`: der Buchstabe steht fuer die Flaeche, und ein
+ * zweiter Druck auf denselben Buchstaben macht sie wieder zu. Ein
+ * Menuepunkt, der nur oeffnet, waere der einzige der Zeile, dessen
+ * `data-state` eine Lage anzeigt, die er selbst nicht umkehren kann.
+ */
+ '?': {
+ title: helpOpen ? messages.menu.helpClose : messages.menu.helpOpen,
+ state: helpOpen ? 'on' : 'off',
+ onSelect: menuActionsRef.current['?'] ?? (() => undefined),
+ },
+ };
+
+ const commandOverlay = overlayOpen ? (
+
+ ) : oneCharTyped ? (
+ /*
+ * Ein einziges Zeichen sucht noch nicht, und das steht jetzt DORT, wo
+ * die Treffer stehen wuerden.
+ *
+ * Nutzerbefund vom 2026-08-29: der Grund stand nur klein am rechten Rand
+ * der Zeile, und wer tippt, sieht nach oben, wo die Antwort erscheint.
+ * Dort stand nichts, also sah es aus, als antworte die Suche gar nicht.
+ * Dasselbe Fenster, dieselbe Stelle, ein Satz statt einer Leere.
+ */
+
+ ) : undefined;
+
+ /*
+ * Wo die Fuehrung steht, als qualifizierter Name.
+ *
+ * Nur fuer den Vorwaerts-Gang: die Fuehrung durchs Projekt hat keinen
+ * Subgraphen, in dem ein Schritt liegen koennte. Ein Dateischritt hat kein
+ * Symbol und liefert deshalb nichts; das Bild sagt dann, dass nichts von
+ * diesem Walk im Fokus steht, statt auf den naechstbesten Punkt zu zeigen.
+ */
+ const walkStep = tour?.kind === 'entry' ? tour.document.steps[tourStep]?.primary : undefined;
+ const walkStepQualifiedName =
+ walkStep !== undefined && walkStep.kind === 'symbol' ? walkStep.qualifiedName : undefined;
+
+ const liveIr = matchingInspectorIr(twinIr, readerFocusSymbol, project, activePath);
+ const liveOutline = useMemo(() => liveIr ? buildPseudocode(
+ { kind: 'symbol', label: liveIr.symbol.name },
+ { irs: [liveIr], ...(layout === undefined ? {} : { graph: layout }) },
+ ) : undefined, [liveIr, layout]);
+ const liveSelection = readerSelection?.path === activePath && document?.path === activePath
+ ? readerSelection : undefined;
+ const currentReaderContext = useMemo(() => readerChatContext({
+ project, path: activePath, status: readerStatus, document, origin: documentOrigin, selection: liveSelection,
+ }), [project, activePath, readerStatus, document, documentOrigin, liveSelection]);
+ const symbolList = fileSymbols.project === project && fileSymbols.path === activePath ? fileSymbols : undefined;
+ const liveCode: SelectedCodeSnapshot = {
+ project, filePath: activePath, symbol: readerFocusSymbol, ir: liveIr,
+ selection: liveSelection, document: document?.path === activePath ? document : undefined,
+ imports: activePath ? imports : undefined,
+ pseudocode: liveOutline,
+ status: liveIr ? 'ready' : readerStatus === 'loading' ? 'loading'
+ : twinStatus === 'failed' ? 'failed' : twinStatus === 'not-indexed' ? 'not-indexed'
+ : readerFocusSymbol ? 'loading' : 'empty',
+ message: twinHint || twinMessage,
+ coverageNote: coverageNote?.text,
+ fileSymbols: symbolList?.symbols,
+ fileSymbolsStatus: symbolList?.status ?? (activePath ? 'loading' : 'ready'),
+ fileSymbolsMessage: symbolList?.message,
+ };
+ const inspector = pinnedCode?.project === project ? pinnedCode : liveCode;
+ const evidenceGraph = repositoryReading.snapshot;
+ const inspectorNode = evidenceGraph?.nodes.find(node => inspector.symbol?.qualifiedName
+ ? node.qualified_name === inspector.symbol.qualifiedName
+ : node.file_path === inspector.filePath && (node.label === 'File' || node.label === 'Module'));
+ const exploreEvidence = ({ filePath, line, name }: SourceEvidenceTarget): void => {
+ setSourceEvidence(undefined);
+ changeWorkspace('explore');
+ setPinnedCode(undefined);
+ const target = twinTargetOf({ filePath, startLine: line, name: name ?? filePath, kind: 'unknown' });
+ if (target) followTarget(target);
+ };
+ const navigateEvidence = (filePath: string, line?: number, name?: string): void => {
+ setSourceEvidence({ project, target: { filePath, line, name } });
+ };
+ const readMapSource = useCallback(async (path: string, startLine = 1, maxLines = 65) => {
+ const qualifiedName = fileQualifiedName(repositoryReading.snapshot, path);
+ if (!qualifiedName) throw new Error(workspaceText.sourceOutsideSnapshot);
+ return client.getCodeSnippet(project, qualifiedName, { startLine, maxLines });
+ }, [client, project, repositoryReading.snapshot]);
+ const clearGraphSelection = () => {
+ setGalaxySelection(undefined);
+ setGalaxySelectionProject('');
+ setChatGraphSelection(undefined);
+ setProactiveSelection(undefined);
+ };
+ const readerImpactTarget = activePath ? { filePath: activePath,
+ qualifiedName: markedGraphRange ? readerFocusSymbol?.qualifiedName : undefined,
+ name: markedGraphRange ? readerFocusSymbol?.name : undefined } : undefined;
+ const selectMapNode = (node: GraphNode): void => {
+ setGalaxySelection(node);
+ setGalaxySelectionProject(project);
+ };
+ const mapSelectionPanel = selectionUnavailable ?
{props.on ? props.status.state === 'connected' ? 'History polling connected' : 'Waiting for daemon history' : 'History polling off'} · {all.length ? <>Latest recorded tool call: . This does not mean an agent is currently working.> : 'No recorded tool call has been received for this project.'}
+
+ ;
+}
diff --git a/graph-ui/src/agents/AgentSetup.test.tsx b/graph-ui/src/agents/AgentSetup.test.tsx
new file mode 100644
index 000000000..a2268864a
--- /dev/null
+++ b/graph-ui/src/agents/AgentSetup.test.tsx
@@ -0,0 +1,34 @@
+// @vitest-environment jsdom
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import AgentSetup, { agentInstallCommand } from './AgentSetup';
+let host: HTMLDivElement; let root: Root;
+beforeEach(() => { (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; host = document.createElement('div'); document.body.append(host); root = createRoot(host); });
+afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); });
+const open = async () => { await act(async () => { const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); }); };
+it('exposes actionable setup without querying or changing anything until opened', async () => {
+ const api = { repoInfo: vi.fn().mockResolvedValue({ rootPath: '/repo with spaces', branch: 'main', remoteUrl: '' }) };
+ await act(async () => root.render());
+ expect(host.querySelector('summary')?.textContent).toBe('Connect your agent');
+ expect(api.repoInfo).not.toHaveBeenCalled();
+ await open();
+ expect(api.repoInfo).toHaveBeenCalledWith('project-a');
+ expect(host.querySelector('pre')?.textContent).toContain("--root '/repo with spaces' --project 'project-a'");
+ expect(host.textContent).toContain('no file contents');
+ expect(host.textContent).toContain('does not configure your coding client');
+ expect(host.textContent).toContain('Other coding clients need their own event adapter');
+});
+it('never presents the previous repository command when a new root fails to load', async () => {
+ const api = { repoInfo: vi.fn().mockResolvedValueOnce({ rootPath: '/repo-a' }).mockRejectedValueOnce(new Error('repository unavailable')) };
+ await act(async () => root.render()); await open();
+ expect(host.querySelector('pre')?.textContent).toContain('/repo-a');
+ await act(async () => root.render());
+ expect(host.querySelector('pre')).toBeNull();
+ expect(host.querySelector('[role="alert"]')?.textContent).toContain('repository unavailable');
+});
+it('quotes project and root values as literal shell words', () => {
+ const command = agentInstallCommand("/repo'; touch /tmp/unwanted", '$(env)', 9749);
+ expect(command).toContain("--root '/repo'\\''; touch /tmp/unwanted'");
+ expect(command).toContain("--project '$(env)'");
+});
diff --git a/graph-ui/src/agents/AgentSetup.tsx b/graph-ui/src/agents/AgentSetup.tsx
new file mode 100644
index 000000000..b97671645
--- /dev/null
+++ b/graph-ui/src/agents/AgentSetup.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react';
+import type { AtlasApi } from '../app/atlas-api';
+import hookSource from '../../agents/hooks/atlas-trace.py?raw';
+
+/** POSIX shell word quoting; paths and indexed names are data, never commands. */
+export const shellWord = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
+export const agentInstallCommand = (root: string, project: string, port: number): string =>
+ `python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root ${shellWord(root)} --project ${shellWord(project)} --daemon-url ${shellWord(`http://127.0.0.1:${port}`)}`;
+
+export default function AgentSetup({ api, project, port }: { api?: Pick; project?: string; port: number }) {
+ const [open, setOpen] = useState(false);
+ const [reading, setReading] = useState<{ project: string; root: string; error: string }>();
+ const [copied, setCopied] = useState('');
+ useEffect(() => {
+ if (!open || !api || !project) return;
+ let disposed = false;
+ setReading(undefined); setCopied('');
+ void api.repoInfo(project).then(info => {
+ if (!disposed) setReading({ project, root: info.rootPath, error: info.rootPath ? '' : 'The daemon did not report this repository root.' });
+ }).catch(error => {
+ if (!disposed) setReading({ project, root: '', error: error instanceof Error ? error.message : String(error) });
+ });
+ return () => { disposed = true; };
+ }, [open, api, project]);
+ const current = reading?.project === project ? reading : undefined;
+ const command = current?.root && project ? agentInstallCommand(current.root, project, port) : '';
+ const download = () => {
+ const url = URL.createObjectURL(new Blob([hookSource], { type: 'text/x-python;charset=utf-8' }));
+ const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'cbm-atlas-trace.py';
+ anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
+ };
+ return setOpen(event.currentTarget.open)}>
+ Connect your agent
+
Claude Code on macOS/Linux · Python 3 required. This installs a project-local PostToolUse hook. Connecting the browser only reads history; it does not configure your coding client.
+ {!project || !api ?
Select an indexed project to generate its setup command.
: <>
+
+
Save cbm-atlas-trace.py in Downloads, or adjust its path in the command below.
+
Review and run this command in your terminal. It copies the hook into .claude/hooks/ and adds it to .claude/settings.local.json, preserving unrelated settings. Conflicting existing hooks are refused.
+ {current?.error ?
Setup unavailable: {current.error}
: command ? <>
+
{command}
{copied}
+ > :
Reading the indexed repository root…
}
+
+
Start a new Claude Code session in that repository and perform a tool call. Then choose Load recorded activity here. A new recorded event is the evidence that the hook reached this daemon.
+
+
Destination: http://127.0.0.1:{port}/api/agent-events · Project: {project}. No bridge or extra listener. Pending events retry on the next tool call.
+ >}
+
The hook records tool names, paths, timestamps, optional line spans and up to 180 characters of a command or search pattern. It records no file contents or tool results. Metadata stays in the local daemon; commands and paths can still contain sensitive information.
+
To remove it, delete only the entry referencing cbm-atlas-trace.py from this project's PostToolUse hooks. Other coding clients need their own event adapter; their activity is not inferred.
+ ;
+}
diff --git a/graph-ui/src/agents/AgentsHud.test.tsx b/graph-ui/src/agents/AgentsHud.test.tsx
new file mode 100644
index 000000000..5e6a800bd
--- /dev/null
+++ b/graph-ui/src/agents/AgentsHud.test.tsx
@@ -0,0 +1,363 @@
+// @vitest-environment jsdom
+/*
+ * Das Instrument, ohne einen gerenderten Pixel.
+ *
+ * Was hier geprueft wird, ist die Ehrlichkeitsregel dieser Flaeche und nicht
+ * ihr Aussehen: eine Absichtszeile NUR dort, wo ein Ereignis sie mitbringt,
+ * und dann gekennzeichnet; keine Zahl, die nicht aus der Sicht kommt; kein
+ * Fortschritt; und die drei Lagen mit genau dem, was in jeder von ihnen
+ * stehen soll. Wie gross der Kasten wirklich wird, misst der Beweislauf im
+ * Browser (tools/smoke-w11a.mjs); jsdom hat kein Layout.
+ */
+
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import AgentsHud from './AgentsHud';
+import type { AgentsHudProps } from './AgentsHud';
+import { readAgentEvent } from './agent-event';
+import { pulseOf } from './agent-motion';
+import type { AgentEvent } from './agent-event';
+import type { ActorView, AgentsView } from './agent-view';
+import type { AgentSourceStatus } from './agent-source';
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+const event = (over: Partial): AgentEvent => readAgentEvent({
+ ts: 1788040000000, agent: 'implementer', run: 'r1', seq: 3, phase: 'end',
+ tool: 'Edit', path: 'src/services/userService.ts', lines: [24, 30], detail: 'changed it',
+ ...over,
+}) as AgentEvent;
+
+const actor = (over: Partial & { id: string }): ActorView => ({
+ name: over.id,
+ you: false,
+ color: 'hsl(200 92% 72%)',
+ letter: over.id.slice(0, 1).toUpperCase(),
+ kind: 'write',
+ kindLetter: 'W',
+ placement: {
+ kind: 'range', nodeId: 51, name: 'createUser', qualifiedName: 'p.createUser',
+ uncertain: false, why: '', ghostIds: [],
+ },
+ node: undefined,
+ testedNode: undefined,
+ ghostNodes: [],
+ last: event({}),
+ lastTs: 1788040000000,
+ hereMs: 12000,
+ count: 9,
+ missed: 0,
+ strip: new Array(30).fill(0).map((_, index) => (index === 29 ? 2 : 0)),
+ stripTotal: 2,
+ intent: '',
+ paths: ['src/services/userService.ts'],
+ sinceMs: 4000,
+ idle: false,
+ recentEvents: 9,
+ pulse: pulseOf(9),
+ trail: [],
+ waves: [],
+ drawn: true,
+ ...over,
+});
+
+const view = (over: Partial = {}): AgentsView => {
+ const actors = over.actors ?? [
+ actor({ id: 'implementer', intent: 'tightening the input check in createUser' }),
+ actor({ id: 'checker', kind: 'test', kindLetter: 'T' }),
+ ];
+ return {
+ actors,
+ all: over.all ?? actors,
+ unmapped: over.unmapped ?? [{
+ ts: 1788039000000, agent: 'checker', tool: 'Read', path: 'package.json',
+ detail: 'read it', why: 'the index has no node for this path',
+ }],
+ events: 45,
+ missed: 2,
+ perMinute: 7,
+ unreadable: 0,
+ cap: 8,
+ capped: 0,
+ ticker: [],
+ ...over,
+ };
+};
+
+const status = (over: Partial = {}): AgentSourceStatus => ({
+ state: 'connected',
+ origin: 'http://127.0.0.1:4142',
+ requests: 1,
+ drops: 0,
+ hello: { mode: 'live', file: '/home/x/.atlas-trace/events.jsonl', events: 45, unreadable: 0 },
+ error: '',
+ ...over,
+});
+
+const props = (over: Partial = {}): AgentsHudProps => ({
+ view: view(),
+ status: status(),
+ port: 4142,
+ size: 'compact',
+ onSize: vi.fn(),
+ filter: 'both',
+ onFilter: vi.fn(),
+ switches: { follow: false, trails: false, fullscreen: false },
+ onSwitch: vi.fn(),
+ trailWindowMs: 60000,
+ onTrailWindow: vi.fn(),
+ layerOn: true,
+ ...over,
+});
+
+async function render(over: Partial = {}): Promise {
+ const use = props(over);
+ await act(async () => {
+ root.render();
+ });
+ return use;
+}
+
+const all = (testId: string): HTMLElement[] =>
+ [...container.querySelectorAll(`[data-testid="${testId}"]`)] as HTMLElement[];
+const one = (testId: string): HTMLElement | null =>
+ container.querySelector(`[data-testid="${testId}"]`);
+const textOf = (testId: string): string =>
+ (one(testId)?.textContent ?? '').replace(/\s+/g, ' ').trim();
+
+describe('die Absichtszeile', () => {
+ it('steht NUR dort, wo ein Ereignis sie mitbringt', async () => {
+ await render();
+ const rows = all('atlas-agents-row');
+ expect(rows).toHaveLength(2);
+ const withIntent = rows.filter(
+ (row) => row.querySelector('[data-testid="atlas-agents-intent"]') !== null,
+ );
+ expect(withIntent).toHaveLength(1);
+ expect(withIntent[0]?.getAttribute('data-actor')).toBe('implementer');
+ });
+
+ it('ist als Selbstauskunft gekennzeichnet und nicht als Messung', async () => {
+ await render();
+ const intent = one('atlas-agents-intent');
+ expect(intent?.getAttribute('data-self-reported')).toBe('true');
+ expect(intent?.textContent).toContain('agent says:');
+ expect(intent?.textContent).toContain('tightening the input check in createUser');
+ });
+
+ it('erscheint nicht, wenn das Ereignis das Feld verliert', async () => {
+ await render({
+ view: view({
+ actors: [actor({ id: 'implementer' })],
+ all: [actor({ id: 'implementer' })],
+ }),
+ });
+ expect(all('atlas-agents-intent')).toHaveLength(0);
+ });
+});
+
+describe('was nicht gedeutet wird', () => {
+ it('zeichnet kein einziges Fortschrittselement', async () => {
+ await render({ size: 'expanded' });
+ expect(container.querySelectorAll('progress, [role="progressbar"], [aria-valuenow]'))
+ .toHaveLength(0);
+ });
+
+ it('nennt keine Prozentzahl und keine Bewertung', async () => {
+ await render({ size: 'expanded' });
+ const text = (container.textContent ?? '').toLowerCase();
+ for (const word of ['%', 'progress', 'thinking', 'almost done', 'score']) {
+ expect(text, word).not.toContain(word);
+ }
+ });
+});
+
+describe('die Zahlen im Kopf', () => {
+ it('kommen aus der Sicht und werden nicht gerechnet', async () => {
+ await render();
+ expect(one('atlas-agents-count')?.getAttribute('data-count')).toBe('2');
+ expect(one('atlas-agents-rate')?.getAttribute('data-per-minute')).toBe('7');
+ expect(one('atlas-agents-order')?.getAttribute('data-missed')).toBe('2');
+ expect(textOf('atlas-agents-order')).toBe('2 events missed');
+ });
+
+ it('sagen "in Ordnung", wenn nichts fehlte', async () => {
+ await render({ view: view({ missed: 0 }) });
+ expect(textOf('atlas-agents-order')).toBe('order intact');
+ });
+
+ it('malen den Streifen aus den gezaehlten Ereignissen', async () => {
+ await render();
+ const strip = one('atlas-agents-strip');
+ expect(strip?.getAttribute('data-bars')?.split(',')).toHaveLength(30);
+ expect(strip?.getAttribute('data-total')).toBe('2');
+ expect(strip?.querySelectorAll('.atlas-agents-bar')).toHaveLength(30);
+ });
+});
+
+describe('die drei Lagen', () => {
+ it('zeigt eingeklappt eine Zeile mit der Zahl und keine Reihe', async () => {
+ await render({ size: 'collapsed' });
+ expect(textOf('atlas-agents-line')).toBe('2 actors on the graph');
+ expect(all('atlas-agents-row')).toHaveLength(0);
+ expect(all('atlas-agents-switch')).toHaveLength(0);
+ });
+
+ it('zeigt kompakt je Akteur eine Zeile und die Zahl der unverortbaren', async () => {
+ await render({ size: 'compact' });
+ expect(all('atlas-agents-row')).toHaveLength(2);
+ expect(all('atlas-agents-card')).toHaveLength(0);
+ expect(one('atlas-agents-unmapped')?.getAttribute('data-count')).toBe('1');
+ expect(all('atlas-agents-unmapped-row')).toHaveLength(0);
+ });
+
+ it('zeigt gross die Karte je Akteur und die Rohereignisse', async () => {
+ await render({ size: 'expanded' });
+ expect(all('atlas-agents-card')).toHaveLength(2);
+ const raw = all('atlas-agents-unmapped-row');
+ expect(raw).toHaveLength(1);
+ expect(raw[0]?.getAttribute('data-path')).toBe('package.json');
+ expect(raw[0]?.textContent).toContain('the index has no node for this path');
+ });
+
+ it('meldet jeden Wechsel der Lage nach oben, statt ihn selbst zu behalten', async () => {
+ const use = await render({ size: 'compact' });
+ act(() => {
+ (one('atlas-agents-expand') as HTMLButtonElement).click();
+ });
+ expect(use.onSize).toHaveBeenCalledWith('expanded');
+ act(() => {
+ (one('atlas-agents-fold') as HTMLButtonElement).click();
+ });
+ expect(use.onSize).toHaveBeenCalledWith('collapsed');
+ });
+});
+
+describe('die Quelle', () => {
+ it('sagt bei ausgeschaltetem Modus, dass nichts gefragt wird', async () => {
+ await render({ status: status({ state: 'off', hello: undefined, requests: 0 }) });
+ expect(textOf('atlas-agents-reading')).toContain('live mode is off');
+ expect(one('atlas-agents-source')?.getAttribute('data-state')).toBe('off');
+ });
+
+ it('nennt bei fehlender Verbindung den vorhandenen Daemon', async () => {
+ await render({
+ status: status({ state: 'no-source', hello: undefined, error: 'refused' }),
+ port: 4711,
+ });
+ expect(textOf('atlas-agents-reading')).toContain('no daemon is answering');
+ expect(one('atlas-agents-command')?.getAttribute('data-command'))
+ .toBe('codebase-memory-mcp --ui');
+ });
+
+ it('gibt eine Wiedergabe als Wiedergabe aus und nicht als Gegenwart', async () => {
+ const replay = status({
+ hello: {
+ mode: 'replay', file: 'fixtures/agent-events/w11a-replay.jsonl',
+ events: 45, unreadable: 0,
+ },
+ });
+ // Kompakt sagt die eine Kopfzeile es, gross steht der Satz ueber die
+ // verschobenen Zeitstempel daneben. Beides zusammen ist die Auskunft;
+ // drei Zeilen dafuer in einem Instrument von 320 Pixeln waeren keine.
+ await render({ status: replay });
+ expect(textOf('atlas-agents-reading')).toContain('replaying');
+ expect(all('atlas-agents-replay')).toHaveLength(0);
+ await render({ status: replay, size: 'expanded' });
+ expect(textOf('atlas-agents-replay')).toContain('recorded earlier');
+ });
+
+ it('sagt, wenn die Ebene in den Einstellungen aus ist', async () => {
+ await render({ layerOn: false });
+ expect(textOf('atlas-agents-layer-off')).toContain('off in the settings');
+ });
+});
+
+describe('die Umschalter', () => {
+ it('melden ihre Wahl nach oben', async () => {
+ const use = await render();
+ act(() => {
+ (container.querySelector('[data-testid="atlas-agents-filter-option"][data-option="you"]') as HTMLButtonElement).click();
+ });
+ expect(use.onFilter).toHaveBeenCalledWith('you');
+ act(() => {
+ (container.querySelector('[data-testid="atlas-agents-switch"][data-switch="follow"]') as HTMLButtonElement).click();
+ });
+ expect(use.onSwitch).toHaveBeenCalledWith('follow');
+ });
+
+ it('zeigen den Zeitraum der Wegzeile erst, wenn es eine gibt', async () => {
+ await render();
+ expect(all('atlas-agents-window-option')).toHaveLength(0);
+ expect(all('atlas-agents-path')).toHaveLength(0);
+ await render({ switches: { follow: false, trails: true, fullscreen: false } });
+ expect(all('atlas-agents-window-option').length).toBeGreaterThanOrEqual(3);
+ expect(all('atlas-agents-path')).toHaveLength(2);
+ });
+
+ it('traegt keinen Knopf ohne Griff', async () => {
+ await render({ size: 'expanded' });
+ for (const button of container.querySelectorAll('button')) {
+ expect(button.getAttribute('type'), button.textContent ?? '').toBe('button');
+ }
+ expect(all('atlas-agents-switch').map((node) => node.getAttribute('data-switch')))
+ .toEqual(['follow', 'trails', 'fullscreen']);
+ });
+});
+
+describe('der Ort in Worten', () => {
+ it('nennt die Zeilen, wo es welche gibt', async () => {
+ await render();
+ expect(textOf('atlas-agents-place')).toBe('createUser, lines 24 to 30');
+ });
+
+ it('sagt bei einem Knoten ohne Endzeile, dass er keinen Bereich traegt', async () => {
+ await render({
+ view: view({
+ actors: [actor({
+ id: 'explorer',
+ kind: 'search',
+ kindLetter: 'S',
+ placement: {
+ kind: 'file', nodeId: 5, name: 'src', qualifiedName: 'p.src',
+ uncertain: true, why: '', ghostIds: [],
+ },
+ })],
+ all: [],
+ }),
+ });
+ expect(textOf('atlas-agents-place')).toBe('src (no line range in the index)');
+ expect(one('atlas-agents-row')?.getAttribute('data-uncertain')).toBe('true');
+ });
+
+ it('sagt bei einem Ereignis ohne Ort, dass es sich nicht verorten laesst', async () => {
+ await render({
+ view: view({
+ actors: [actor({
+ id: 'checker',
+ placement: {
+ kind: 'none', name: '', qualifiedName: '', uncertain: false,
+ why: 'the index has no node for this path', ghostIds: [],
+ },
+ })],
+ all: [],
+ }),
+ });
+ expect(textOf('atlas-agents-place')).toBe('not placeable on the graph');
+ });
+});
diff --git a/graph-ui/src/agents/AgentsHud.tsx b/graph-ui/src/agents/AgentsHud.tsx
new file mode 100644
index 000000000..c22e46018
--- /dev/null
+++ b/graph-ui/src/agents/AgentsHud.tsx
@@ -0,0 +1,759 @@
+/**
+ * Das Instrument unten rechts: es erklaert, was auf dem Graphen zu sehen ist.
+ *
+ * Es ist ausdruecklich KEIN Dashboard. Was es zeigt, sind die Koerper, die
+ * daneben kreisen, in Worten: wer, welche Art von Arbeit, an welchem Ort, seit
+ * wann, und wie dicht die Ereignisse in den letzten dreissig Sekunden lagen.
+ * Jede Zahl darin ist gezaehlt.
+ *
+ * ## Drei Groessen, und warum es drei sein muessen
+ *
+ * Die schriftliche Gegenrede zum Entwurf verlangt ein sehr kompaktes Instrument
+ * (Richtwert 320 mal 150 Pixel), das Designbild des Nutzers zeigt eine deutlich
+ * groessere Karte je Agent mit Wegzeile, und es traegt selbst einen
+ * EXPAND-Knopf. Beides ist richtig, aber nicht gleichzeitig:
+ *
+ * - **kompakt** ist der Normalzustand, weil das Instrument den Graphen erklaeren
+ * und ihn nicht zudecken soll. Eine Zeile je Akteur, und alles, was in dieser
+ * Zeile nicht Platz hat, kuerzt sich mit einem Auslassungszeichen und steht
+ * vollstaendig im Tooltip und in der grossen Lage.
+ * - **expand** zeigt die ausfuehrliche Karte je Akteur, so wie das Bild sie
+ * zeigt: den ganzen Ort, die Wegzeile, die Zahlen der Laeufe, das letzte
+ * Ereignis im Wortlaut, und die Liste dessen, was sich nicht verorten liess.
+ * - **eingeklappt** bleibt eine Zeile, die sagt, wie viele Akteure laufen. Ein
+ * Instrument, das ganz verschwindet, laesst den Leser vor kreisenden Punkten
+ * ohne Erklaerung sitzen.
+ *
+ * Die gewaehlte Groesse liegt im Speicher des Browsers und ueberlebt den Reload.
+ *
+ * ## Die Ehrlichkeitsregel dieser Flaeche
+ *
+ * Drei Dinge stehen hier NICHT, und alle drei fehlen mit Absicht:
+ *
+ * 1. **Kein Fortschritt.** Weder ein Balken noch eine Prozentzahl noch ein
+ * "fast fertig". Diese Oberflaeche sieht Werkzeugaufrufe, nicht Vorhaben;
+ * wie weit ein Agent ist, weiss sie nicht und kann sie nicht wissen.
+ * 2. **Keine Bewertung.** Kein "gut", kein "haengt", kein "denkt nach". Eine
+ * Pause zwischen zwei Ereignissen ist eine Pause zwischen zwei Ereignissen.
+ * 3. **Keine erfundene Absicht.** Eine Absichtszeile erscheint NUR, wenn das
+ * Ereignis ein `intent`-Feld mitbringt, das der Agent selbst geschrieben
+ * hat, und dann in eigener Schriftfarbe, mit dem Praefix "agent says:" und
+ * einem Tooltip, der sagt, dass es gemeldet und nicht gemessen ist. Das
+ * Designbild zeigt an dieser Stelle Zeilen wie "Refactoring
+ * OrderService.createOrder"; genau diese Zeile ist verboten, solange kein
+ * Ereignis sie traegt.
+ */
+
+import type { JSX } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import Hint from '../ui/tooltip/Hint';
+import { STRIP_SECONDS } from './agent-store';
+import { TRAIL_WINDOWS } from './agent-view';
+import type { ActorFilter, ActorView, AgentsView, HudSize } from './agent-view';
+import { WORK_KIND_WORD, agentStrings as text } from './agent-strings';
+import type { AgentSourceStatus } from './agent-source';
+import { bridgeCommand } from './agent-source';
+
+/** Die drei Umschalter des laufenden Blicks. */
+export interface HudSwitches {
+ follow: boolean;
+ trails: boolean;
+ /**
+ * Das Vollbild.
+ *
+ * Hiess bis W11b `cinema`. Der Nutzer hat es am 2026-08-30 benutzt und
+ * woertlich gesagt: "bin jetzt im cinema mode, sollte fullscreen heissen."
+ */
+ fullscreen: boolean;
+}
+
+export interface AgentsHudProps {
+ view: AgentsView;
+ status: AgentSourceStatus;
+ /** Der Port der Bruecke, fuer den Befehl, der sie startet. */
+ port: number;
+ size: HudSize;
+ onSize: (size: HudSize) => void;
+ filter: ActorFilter;
+ onFilter: (filter: ActorFilter) => void;
+ switches: HudSwitches;
+ onSwitch: (name: keyof HudSwitches) => void;
+ trailWindowMs: number;
+ onTrailWindow: (ms: number) => void;
+ /** Ob die Zeichenebene ueberhaupt an ist (Einstellungen, W10-Gruppe). */
+ layerOn: boolean;
+ /**
+ * Ob das Instrument als Spalte am Rand steht (Vollbild, W11b AC5).
+ *
+ * In der Spalte bekommt jeder Akteur ZWEI Zeilen statt einer, und das ist
+ * die Antwort auf einen Befund am Beweisbild von W11a: in einer Spalte von
+ * dreihundert Pixeln schrumpfen die Namen auf vier Zeichen ("expl...",
+ * "chec..."), und sauber gekuerzt ist immer noch gekuerzt. "chec..." sagt
+ * niemandem, wer da arbeitet. Zwei Zeilen kosten Hoehe, und Hoehe ist genau
+ * das, was eine Spalte im Vollbild hat.
+ */
+ column?: boolean;
+}
+
+const FILTERS: readonly ActorFilter[] = ['you', 'agent', 'both'];
+
+function filterLabel(option: ActorFilter): string {
+ return option === 'you' ? text.filterYou : option === 'agent' ? text.filterAgent : text.filterBoth;
+}
+
+/** Die Zeitspanne in Worten. Sekunden bis eine Minute, danach Minuten. */
+function spell(ms: number): string {
+ return text.ago(ms / 1000);
+}
+
+/** Der Ort in Worten, samt der Frage, wie sicher er ist. */
+function placeText(actor: ActorView): string {
+ const place = actor.placement;
+ if (place.kind === 'none') {
+ return text.placeNone;
+ }
+ if (place.uncertain) {
+ return text.placeUncertain(place.name);
+ }
+ if (place.kind === 'range' && actor.last.lines !== undefined) {
+ return text.placeRange(place.name, actor.last.lines[0], actor.last.lines[1]);
+ }
+ return text.placeFile(place.name);
+}
+
+/** Der Aktivitaetsstreifen: ein Balken je Sekunde, aus echten Ereignissen. */
+function Strip(props: { actor: ActorView }): JSX.Element {
+ const peak = Math.max(1, ...props.actor.strip);
+ return (
+
+
+ {props.actor.strip.map((count, index) => (
+
+ ))}
+
+
+ );
+}
+
+/** Die Absichtszeile. Sie gibt es nur, wenn das Ereignis sie mitbringt. */
+function Intent(props: { actor: ActorView }): JSX.Element | null {
+ if (props.actor.intent.length === 0) {
+ return null;
+ }
+ return (
+
+
+ {text.intentPrefix}
+ {` ${props.actor.intent}`}
+
+
+ );
+}
+
+/** Der Ort, mit seinem Tooltip. In beiden Lagen derselbe Text. */
+function Place(props: { actor: ActorView; className: string }): JSX.Element {
+ const { actor } = props;
+ return (
+
+
+ {placeText(actor)}
+
+
+ );
+}
+
+/**
+ * Eine Zeile je Akteur.
+ *
+ * Kompakt ist sie EINE Zeile: Farbpunkt, Name, Buchstabe der Art, Ort, Streifen,
+ * Dauer. Was nicht hineinpasst, kuerzt sich mit einem Auslassungszeichen; der
+ * ganze Wortlaut steht im Tooltip und in der grossen Lage. Eine Absichtszeile
+ * kommt darunter, aber nur, wenn das Ereignis eine mitbringt.
+ */
+function Row(props: {
+ actor: ActorView;
+ trails: boolean;
+ expanded: boolean;
+ column: boolean;
+}): JSX.Element {
+ const { actor, expanded, column } = props;
+ const place = ;
+ return (
+
+
+
+
+ {actor.name}
+
+
+
+ {actor.kindLetter}
+
+
+ {!expanded && !column && place}
+ {!column && }
+
+
+ {spell(actor.hereMs)}
+
+
+
+ {/*
+ * Die zweite Zeile der Spalte: Ort und Streifen.
+ *
+ * Sie hat ihren eigenen Kasten und nicht nur einen Umbruch, damit
+ * der Ort die ganze Breite bekommt. Das ist der Unterschied
+ * zwischen "chec..." und "checker" und zwischen "hots..." und
+ * "hotspotScan".
+ */}
+ {column && (
+
+ {place}
+
+
+ )}
+ {expanded && !column && (
+
+ )}
+
+ {expanded && actor.kind === 'test' && (
+
+ {actor.testedNode === undefined
+ ? text.testedUnknown
+ : text.testedTitle(actor.testedNode.name)}
+
+ )}
+ {/*
+ * Die Verlaufszeile.
+ *
+ * In der Spalte steht sie immer, denn genau sie ist die
+ * "Verlaufszeile je Agent", die AC5 dort verlangt. Im Panel
+ * haengt sie weiter am Schalter TRAILS, weil eine Zeile mehr je
+ * Akteur dort der Unterschied zwischen einem Instrument und einem
+ * Panel ist.
+ */}
+ {(props.trails || column) && (
+
+ {actor.paths.length === 0 ? text.pathEmpty : actor.paths.join(' < ')}
+
+ )}
+ {expanded && (
+
+
+ {`${actor.count} events, ${
+ actor.missed === 0 ? text.orderIntact : text.orderMissed(actor.missed)
+ }`}
+
+
+ {`${actor.last.tool} ${actor.last.detail}`}
+
+
+ )}
+
+ );
+}
+
+export default function AgentsHud(props: AgentsHudProps): JSX.Element {
+ const { view, status, size } = props;
+ const collapsed = size === 'collapsed';
+ const expanded = size === 'expanded';
+ const column = props.column === true;
+
+ /*
+ * Die Kante des Kastens, gemessen wie bei der Legende der Galaxie.
+ *
+ * Dieselbe Begruendung wie dort: der Bildlauf dieser Plattform ist eine
+ * ueberlagernde Leiste, die im Ruhezustand unsichtbar ist. Ohne einen
+ * eigenen Hinweis endet der letzte sichtbare Satz an einer harten Kante, und
+ * das liest sich als Fehler und nicht als Fortsetzung.
+ */
+ const body = useRef(null);
+ const [edge, setEdge] = useState({ above: false, below: false });
+ const measure = useCallback(() => {
+ const node = body.current;
+ if (node === null) {
+ return;
+ }
+ const above = node.scrollTop > 1;
+ const below = node.scrollTop + node.clientHeight < node.scrollHeight - 1;
+ setEdge((current) =>
+ (current.above === above && current.below === below ? current : { above, below }));
+ }, []);
+ useEffect(() => {
+ measure();
+ }, [measure, size, view.actors, props.switches.trails]);
+ useEffect(() => {
+ const node = body.current;
+ if (node === null || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [measure, size]);
+
+ const sourceHead = (): string => {
+ if (status.state === 'off') {
+ return text.sourceOff;
+ }
+ if (status.state === 'connecting') {
+ return text.sourceConnecting;
+ }
+ if (status.state === 'no-source') {
+ return text.sourceNone;
+ }
+ const hello = status.hello;
+ return hello?.mode === 'replay'
+ ? text.sourceReplay(hello.file)
+ : text.sourceConnected(hello?.file ?? '');
+ };
+
+ /*
+ * Der ausfuehrliche Teil der Quelle steht nur dort, wo er gebraucht wird:
+ * wenn es keine Quelle gibt (dann traegt er den Befehl, der sie startet)
+ * und in der grossen Lage. Im Normalfall haette er in einem Instrument von
+ * 320 Pixeln drei Zeilen belegt, um zu sagen, was die eine Zeile darueber
+ * schon sagt.
+ */
+ const showSourceDetail = expanded || status.state === 'no-source' || status.state === 'off';
+
+ return (
+
+ );
+}
diff --git a/graph-ui/src/agents/AgentsTimeline.tsx b/graph-ui/src/agents/AgentsTimeline.tsx
new file mode 100644
index 000000000..a2bc98f42
--- /dev/null
+++ b/graph-ui/src/agents/AgentsTimeline.tsx
@@ -0,0 +1,216 @@
+/**
+ * Der Zeitstrahl unter dem Graphen: eine Spur je Akteur, ein Strich je
+ * Ereignis.
+ *
+ * ## Warum er ein Strich je Ereignis ist und keine Kurve
+ *
+ * Weil eine Kurve eine Aussage waere. Sie muesste glaetten, und Glaetten heisst
+ * hier, zwischen zwei Werkzeugaufrufen eine Dichte zu behaupten, die niemand
+ * gemessen hat. Ein Strich je Ereignis ist die Ablesung: wo einer steht, war
+ * ein Aufruf fertig; wo keiner steht, war keiner. Die Luecken sind dabei die
+ * interessantere Haelfte des Bildes.
+ *
+ * ## Die drei Lagen und die eine Gefahr
+ *
+ * `live` laeuft mit, `paused` haelt das Fenster an (die Ereignisse laufen
+ * weiter ein), `replay` zeigt den Zustand von damals. Die dritte ist die
+ * gefaehrliche: ein alter Zustand sieht aus wie der jetzige. Sie kennzeichnet
+ * sich darum nicht nur hier, sondern faerbt die ganze Ansicht (siehe
+ * `data-replay` am Panel) und sagt in Worten, wie weit zurueck sie steht.
+ */
+
+import type { JSX, MouseEvent as ReactMouseEvent } from 'react';
+import { useCallback, useRef } from 'react';
+
+import Hint from '../ui/tooltip/Hint';
+import { agentStrings as text } from './agent-strings';
+import { TIMELINE_WINDOWS, timeAtFraction } from './agent-timeline';
+import type { Timeline } from './agent-timeline';
+
+export interface AgentsTimelineProps {
+ timeline: Timeline;
+ /** Die Gegenwart, fuer den Satz "so weit zurueck". */
+ now: number;
+ windowMs: number;
+ onWindow: (ms: number) => void;
+ /** Anhalten und weiterlaufen lassen. */
+ onPause: () => void;
+ /** Auf einen Zeitpunkt springen. */
+ onScrub: (ts: number) => void;
+ /** Zurueck in die Gegenwart. */
+ onLive: () => void;
+}
+
+function modeLabel(timeline: Timeline): string {
+ return timeline.mode === 'replay'
+ ? text.timelineReplay
+ : timeline.mode === 'paused' ? text.timelinePaused : text.timelineLive;
+}
+
+export default function AgentsTimeline(props: AgentsTimelineProps): JSX.Element {
+ const { timeline } = props;
+ const lanes = useRef(null);
+
+ const scrub = useCallback((event: ReactMouseEvent) => {
+ const box = lanes.current?.getBoundingClientRect();
+ if (box === undefined || box.width === 0) {
+ return;
+ }
+ props.onScrub(timeAtFraction(timeline, (event.clientX - box.x) / box.width));
+ }, [props, timeline]);
+
+ return (
+
+ {/*
+ * Der Klickbereich ist die Flaeche der Spuren und nicht ein
+ * eigener Streifen darunter: der Leser zeigt auf den Strich,
+ * den er meint. Ein `div` mit einem Klick und ohne Rolle waere
+ * fuer die Tastatur nicht da; die Rolle `slider` sagt, was es
+ * ist, und die drei `aria`-Werte sagen, wo es steht.
+ */}
+
+ );
+}
diff --git a/graph-ui/src/agents/activity-model.test.ts b/graph-ui/src/agents/activity-model.test.ts
new file mode 100644
index 000000000..47c555ac8
--- /dev/null
+++ b/graph-ui/src/agents/activity-model.test.ts
@@ -0,0 +1,27 @@
+import { expect, it } from 'vitest';
+import { activityRows } from './activity-model';
+import { emptyAgentsState, withEvent } from './agent-store';
+import type { AgentEvent } from './agent-event';
+
+const event = (patch: Partial): AgentEvent => ({
+ agent: 'Ada', run: 'one', seq: 1, ts: 1000, tool: 'Read', phase: 'end',
+ path: 'src/main.ts', detail: 'inspect', source: 'bridge', replay: false, ...patch,
+});
+const state = [event({}), event({ seq: 2, ts: 2000, tool: 'Edit', path: 'missing.ts' }),
+ event({ agent: 'Ben', run: 'two', ts: 3000, path: '' })]
+ .reduce(withEvent, emptyAgentsState());
+const all = { agent: '', run: '', kind: '' as const };
+
+it('keeps unmapped events and orders recorded activity newest first', () => {
+ expect(activityRows(state, all).map((row) => row.path)).toEqual(['', 'missing.ts', 'src/main.ts']);
+});
+it('intersects agent, run and work kind filters', () => {
+ expect(activityRows(state, { agent: 'Ada', run: 'one', kind: 'write' }))
+ .toEqual([event({ seq: 2, ts: 2000, tool: 'Edit', path: 'missing.ts' })]);
+ expect(activityRows(state, { ...all, agent: 'Ben', run: 'one' })).toEqual([]);
+});
+it('does not change the store when ordering or filtering', () => {
+ const before = state.actors.map((actor) => actor.events.map((row) => row.seq));
+ activityRows(state, all);
+ expect(state.actors.map((actor) => actor.events.map((row) => row.seq))).toEqual(before);
+});
diff --git a/graph-ui/src/agents/activity-model.ts b/graph-ui/src/agents/activity-model.ts
new file mode 100644
index 000000000..f70b64790
--- /dev/null
+++ b/graph-ui/src/agents/activity-model.ts
@@ -0,0 +1,12 @@
+import type { AgentEvent, WorkKind } from './agent-event';
+import { workKindOf } from './agent-event';
+import type { AgentsState } from './agent-store';
+
+export interface ActivityFilter { agent: string; run: string; kind: WorkKind | '' }
+export function activityRows(state: AgentsState, filter: ActivityFilter): AgentEvent[] {
+ return state.actors.filter((actor) => !actor.you).flatMap((actor) => actor.events)
+ .filter((event) => (!filter.agent || event.agent === filter.agent)
+ && (!filter.run || event.run === filter.run)
+ && (!filter.kind || workKindOf(event.tool, event.detail) === filter.kind))
+ .sort((a, b) => b.ts - a.ts || b.seq - a.seq || a.agent.localeCompare(b.agent));
+}
diff --git a/graph-ui/src/agents/activity-strings.ts b/graph-ui/src/agents/activity-strings.ts
new file mode 100644
index 000000000..5bfb36681
--- /dev/null
+++ b/graph-ui/src/agents/activity-strings.ts
@@ -0,0 +1,19 @@
+export const activityStrings = {
+ title: 'Agent activity', subtitle: 'Follow recorded work and inspect the code it touches.',
+ connect: 'Load recorded activity', disconnect: 'Disconnect', agent: 'Agent', run: 'Run', kind: 'Activity',
+ allAgents: 'All agents', allRuns: 'All runs', allKinds: 'All activity',
+ events: 'Recorded events', actors: 'Agents seen',
+ gaps: 'Missing sequence numbers', dropped: 'Connection drops', unmapped: 'Unmapped',
+ noSource: 'Load recorded activity from this daemon. Hooks send events to /api/agent-events on the same port.',
+ noEvents: 'No recorded activity for this repository. Configure the tool hook with the indexed project name; tool calls are observed, never inferred.', noMatch: 'No activity matches these filters.',
+ clear: 'Clear filters', evidence: 'Event evidence', open: 'Open source', select: 'Select an event to inspect its evidence.',
+ retained: 'Showing retained history: up to 80 events per agent. Counts are observed totals, not task progress.',
+ outcome: 'An end event records the end of a tool call. Its result is not reported by this stream.',
+ mapping: 'Mapping', timestamp: 'Recorded time', source: 'Source', phase: 'Phase', details: 'Details',
+ replay: 'Replay', live: 'Recorded', unknown: 'Not reported', reset: 'Reset',
+ disconnected: 'Stream is off. Retained events remain available.',
+ limited: 'Graph placement uses the loaded map. Unmapped events remain visible.',
+ noGraphMatch: 'No matching location in the loaded graph. The index may contain more nodes.',
+ unreadable: 'Unreadable records skipped by the stream',
+ kinds: ['read', 'write', 'search', 'test', 'other'] as const,
+};
diff --git a/graph-ui/src/agents/activity.css b/graph-ui/src/agents/activity.css
new file mode 100644
index 000000000..2cfdf4a0e
--- /dev/null
+++ b/graph-ui/src/agents/activity.css
@@ -0,0 +1,48 @@
+.cbm-activity { padding: 28px; max-width: 1600px; margin: auto; }
+.cbm-page-head { display: flex; justify-content: space-between; gap: 24px; align-items: center; margin-bottom: 24px; }
+.cbm-page-head h2 { font-size: 26px; font-weight: 600; letter-spacing: -.03em; margin: 0 0 6px; }
+.cbm-page-head p { margin: 0; color: var(--atlas-dim); }
+.cbm-page-head .cbm-eyebrow { color: var(--atlas-phosphor); text-transform: uppercase; letter-spacing: .14em; font-size: 10px; margin-bottom: 8px; }
+.cbm-activity button, .cbm-activity select, .cbm-activity input { font: inherit; color: var(--atlas-fg); border: 1px solid var(--atlas-line); border-radius: 6px; background: var(--atlas-panel); padding: 8px 12px; }
+.cbm-activity button { cursor: pointer; }
+.cbm-activity button:hover { border-color: var(--atlas-phosphor-mute); }
+.cbm-activity-connection { display: flex; align-items: center; gap: 12px; }
+.cbm-activity-connection > span { color: var(--atlas-dim); font-size: 12px; }
+.cbm-activity-connection > span[data-state=connected] { color: var(--atlas-phosphor); }
+.cbm-activity-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
+.cbm-activity-metrics > div { background: var(--atlas-panel); border: 1px solid var(--atlas-line); border-radius: 8px; padding: 16px 20px; display: flex; flex-direction: column; }
+.cbm-activity-metrics strong { font-size: 24px; font-weight: 500; }
+.cbm-activity-metrics span { color: var(--atlas-dim); font-size: 11px; }
+.cbm-activity-notice { padding: 12px; background: var(--atlas-panel-raised); border-radius: 6px; }
+.cbm-activity-filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
+.cbm-activity-filters input { flex: 1; min-width: 220px; }
+.cbm-activity-body { display: grid; grid-template-columns: minmax(0, 1fr) 320px; border: 1px solid var(--atlas-line); border-radius: 8px; overflow: hidden; min-height: 330px; }
+.cbm-activity-list { max-height: 58vh; overflow: auto; }
+.cbm-activity-list .cbm-activity-row { width: 100%; display: grid; grid-template-columns: 8px minmax(0,1fr) auto; align-items: baseline; gap: 12px; border: 0; border-bottom: 1px solid var(--atlas-line); border-radius: 0; text-align: left; padding: 14px 16px; background: transparent; }
+.cbm-activity-list .cbm-activity-row[aria-pressed=true] { background: var(--atlas-select); }
+.cbm-activity-action { margin-left: 10px; color: var(--atlas-cyan); }
+.cbm-activity-row code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 4px 0; font-size: 11px; font-family: var(--atlas-mono); }
+.cbm-activity-row small, .cbm-activity-row time { color: var(--atlas-dim); font-size: 10px; }
+.cbm-activity-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--atlas-dim); }
+.cbm-activity-dot[data-kind=write] { background: var(--atlas-phosphor); }
+.cbm-activity-dot[data-kind=read] { background: var(--atlas-cyan); }
+.cbm-activity-empty { padding: 56px 24px; text-align: center; color: var(--atlas-dim); }
+.cbm-activity-inspector { border-left: 1px solid var(--atlas-line); background: var(--atlas-panel); padding: 20px; overflow-wrap: anywhere; min-width: 0; }
+.cbm-activity-inspector h3 { margin: 0 0 20px; font-size: 13px; }
+.cbm-activity-inspector h4 { font-size: 12px; margin: 16px 0 8px; }
+.cbm-activity-inspector p, .cbm-activity-note, .cbm-activity footer { color: var(--atlas-dim); font-size: 11px; }
+.cbm-activity-inspector dl > div { display: flex; gap: 12px; justify-content: space-between; margin: 8px 0; font-size: 11px; }
+.cbm-activity-inspector dt { color: var(--atlas-dim); }
+.cbm-activity-inspector dd { margin: 0; text-align: right; }
+.cbm-activity-inspector pre { white-space: pre-wrap; font: 11px var(--atlas-mono); }
+.cbm-activity-inspector > button { margin-top: 12px; }
+@media(max-width: 850px) { .cbm-activity { padding: 16px; } .cbm-activity-body { grid-template-columns: 1fr; } .cbm-activity-inspector { border-left: 0; border-top: 1px solid var(--atlas-line); } .cbm-activity-metrics { grid-template-columns: repeat(2, 1fr); } }
+
+.cbm-agent-setup { border: 1px solid #365548; border-radius: 7px; padding: 9px 12px; background: #101b16; margin: 12px 0; }
+.cbm-agent-setup > summary { cursor: pointer; color: #99e7be; font-weight: 600; }
+.cbm-agent-setup p, .cbm-agent-setup li { color: #bbcec3; font-size: 13px; line-height: 1.5; }
+.cbm-agent-setup li { margin: 12px 0; }.cbm-agent-setup code { overflow-wrap: anywhere; }
+.cbm-agent-setup pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; background: #07100b; border-radius: 5px; }
+.cbm-agent-setup button { border: 1px solid #496b59; background: #182d21; color: #dbf3e4; padding: 5px 9px; border-radius: 4px; cursor: pointer; }
+.cbm-agent-setup :focus-visible { outline: 2px solid #69ddbe; outline-offset: 3px; }
+.cbm-activity-source-status { color: #a7bfb0; font-size: 12px; line-height: 1.5; }
diff --git a/graph-ui/src/agents/agent-colors.test.ts b/graph-ui/src/agents/agent-colors.test.ts
new file mode 100644
index 000000000..853fbb515
--- /dev/null
+++ b/graph-ui/src/agents/agent-colors.test.ts
@@ -0,0 +1,105 @@
+/*
+ * Die Farbe und der Buchstabe eines Akteurs: zwei Zusicherungen, beide
+ * pruefbar ohne einen gerenderten Pixel.
+ *
+ * **Dieselbe Kennung, dieselbe Farbe.** Sonst tauschen zwei Agenten die Farbe,
+ * sobald einer frueher eintrifft, und der Leser sieht eine Bewegung, die es
+ * nicht gab.
+ *
+ * **Der Farbton liegt NIE im warmen Viertel.** Dort liegen die Knotenfarben
+ * dieses Graphen (Sternklassen). Ein Agentenkoerper in einer Knotenfarbe waere
+ * genau die Verwechslung, gegen die die eigene Ebene gebaut ist.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ AGENT_HUE_END,
+ AGENT_HUE_START,
+ YOU_COLOR,
+ YOU_ID,
+ agentColor,
+ agentHue,
+ agentLetters,
+ hashOf,
+} from './agent-colors';
+
+const NAMES = [
+ 'implementer', 'checker', 'explorer', 'reviewer', 'auditor', 'orchestrator',
+ 'agent', 'file-writes', 'a', 'zzz', 'Agent 7', 'run-1234',
+];
+
+describe('die Farbe', () => {
+ it('haengt nur an der Kennung und an nichts sonst', () => {
+ for (const name of NAMES) {
+ expect(agentColor(name)).toBe(agentColor(name));
+ expect(hashOf(name)).toBe(hashOf(name));
+ }
+ });
+
+ it('liegt immer im kuehlen Band und nie bei den Sternfarben der Knoten', () => {
+ for (const name of NAMES) {
+ const hue = agentHue(name);
+ expect(hue, name).toBeGreaterThanOrEqual(AGENT_HUE_START);
+ expect(hue, name).toBeLessThan(AGENT_HUE_END);
+ }
+ // Das warme Viertel, in dem die Knoten dieser Fixture liegen (#ff6050
+ // bis #fff0c0, also Farbton 15 bis 45), ist ausgeschlossen.
+ expect(AGENT_HUE_START).toBeGreaterThan(45);
+ });
+
+ it('gibt dem Leser eine eigene Farbe ausserhalb des Bandes', () => {
+ expect(agentColor(YOU_ID)).toBe(YOU_COLOR);
+ expect(NAMES.map(agentColor)).not.toContain(YOU_COLOR);
+ });
+});
+
+describe('der Buchstabe', () => {
+ it('nimmt den Anfang des Namens', () => {
+ const letters = agentLetters([
+ { id: 'implementer', name: 'implementer' },
+ { id: 'checker', name: 'checker' },
+ { id: 'explorer', name: 'explorer' },
+ ]);
+ expect(letters.get('implementer')).toBe('I');
+ expect(letters.get('checker')).toBe('C');
+ expect(letters.get('explorer')).toBe('E');
+ });
+
+ it('gibt zwei Akteuren mit demselben Anfang NIE denselben Buchstaben', () => {
+ const letters = agentLetters([
+ { id: 'checker', name: 'checker' },
+ { id: 'compiler', name: 'compiler' },
+ { id: 'collector', name: 'collector' },
+ ]);
+ const seen = [...letters.values()];
+ expect(new Set(seen).size).toBe(seen.length);
+ });
+
+ it('haelt das Y des Lesers frei, auch neben einem Agenten mit Y', () => {
+ const letters = agentLetters([
+ { id: 'yara', name: 'yara' },
+ { id: YOU_ID, name: 'you' },
+ ]);
+ expect(letters.get(YOU_ID)).toBe('Y');
+ expect(letters.get('yara')).not.toBe('Y');
+ });
+
+ it('vergibt in der Ordnung der Kennungen, damit der Reload nichts vertauscht', () => {
+ const actors = [
+ { id: 'checker', name: 'checker' },
+ { id: 'compiler', name: 'compiler' },
+ ];
+ const first = agentLetters(actors);
+ const second = agentLetters([...actors].reverse());
+ expect([...second.entries()].sort()).toEqual([...first.entries()].sort());
+ });
+
+ it('bleibt bei einem Namen ohne Buchstaben trotzdem eindeutig', () => {
+ const letters = agentLetters([
+ { id: '1', name: '???' },
+ { id: '2', name: '???' },
+ ]);
+ expect(new Set(letters.values()).size).toBe(2);
+ });
+});
diff --git a/graph-ui/src/agents/agent-colors.ts b/graph-ui/src/agents/agent-colors.ts
new file mode 100644
index 000000000..79d99eadc
--- /dev/null
+++ b/graph-ui/src/agents/agent-colors.ts
@@ -0,0 +1,141 @@
+/**
+ * Die Farbe und der Buchstabe eines Akteurs.
+ *
+ * ## Warum die Farbe aus der Kennung kommt und nicht aus einer Liste
+ *
+ * Weil derselbe Agent nach einem Reload dieselbe Farbe haben muss. Eine Liste,
+ * die der Reihe nach vergeben wird, gibt dem Agenten die Farbe des Platzes, auf
+ * dem er gerade steht: kommt ein zweiter frueher an, tauschen beide die Farbe,
+ * und der Leser sieht eine Bewegung, die es nicht gab. Die Farbe hier ist eine
+ * Funktion der Kennung, sonst nichts. Zwei Fenster, zwei Laeufe, zwei Tage:
+ * dieselbe Kennung, dieselbe Farbe.
+ *
+ * ## Warum es ein Farbton-Band ist und keine sechs festen Farben
+ *
+ * Sechs feste Farben sind bei sieben Agenten zweimal dieselbe. Das Band gibt
+ * jeder Kennung ihren eigenen Ton und laesst trotzdem die Familie erkennen.
+ *
+ * Und warum GENAU dieses Band: die Knotenfarben dieses Graphen sind Sternfarben
+ * und liegen im warmen Viertel (in der Fixture gemessen: #ff6050 bis #fff0c0,
+ * also Farbton 15 bis 45). Das Band der Agenten beginnt bei 140 und endet bei
+ * 340, also Gruen ueber Cyan und Blau bis Magenta. Damit kann ein Agentenkoerper
+ * nie die Farbe eines Knotens tragen, und die Ebene faerbt keinen einzigen
+ * Knoten und keine einzige Kante um: sie legt sich darueber.
+ *
+ * Die Helligkeit ist fest und hoch (72 Prozent bei 92 Prozent Saettigung). Ein
+ * kleiner Koerper von zehn Pixeln auf einem dunklen Grund muss leuchten, um
+ * ueberhaupt eine Farbe zu haben, und ein Band, in dem auch die Helligkeit
+ * variiert, haette dunkle Mitglieder, die niemand sieht.
+ *
+ * ## Der Buchstabe
+ *
+ * Er ist der zweite Unterschied, damit die Unterscheidung nicht allein an der
+ * Farbe haengt: wer Farben nicht trennen kann, liest den Buchstaben. Er kommt
+ * aus dem Namen, und wenn zwei Namen mit demselben Zeichen beginnen, bekommt der
+ * zweite das naechste Zeichen seines eigenen Namens, das noch frei ist. Vergeben
+ * wird in der Ordnung der Kennungen und nicht in der Reihenfolge des Eintreffens,
+ * damit auch das den Reload ueberlebt.
+ */
+
+/** Der Anfang des Farbtonbandes, in Grad. Gruen. */
+export const AGENT_HUE_START = 140;
+
+/** Das Ende des Bandes, in Grad. Magenta. */
+export const AGENT_HUE_END = 340;
+
+/** Die Saettigung jedes Agentenkoerpers, in Prozent. */
+export const AGENT_SATURATION = 92;
+
+/** Die Helligkeit jedes Agentenkoerpers, in Prozent. */
+export const AGENT_LIGHTNESS = 72;
+
+/**
+ * Die Farbe des Lesers.
+ *
+ * Fast weiss und ausdruecklich ausserhalb des Bandes: der Leser ist kein Agent,
+ * und seine Spur soll auch dann nicht wie eine fremde aussehen, wenn zufaellig
+ * ein Agent in der Naehe seines Farbtons liegt.
+ */
+export const YOU_COLOR = '#F2F5FF';
+
+/** Die Kennung, unter der die eigene Navigation laeuft. */
+export const YOU_ID = 'you';
+
+/** Der Buchstabe des Lesers. */
+export const YOU_LETTER = 'Y';
+
+/**
+ * Ein 32-Bit-Streuwert einer Zeichenkette (FNV-1a).
+ *
+ * Ausgeschrieben und nicht aus einer Bibliothek, weil er Teil der Zusicherung
+ * ist: die Farbe eines Agenten haengt an dieser Rechnung, und sie darf sich
+ * nicht mit einer Abhaengigkeit aendern.
+ */
+export function hashOf(value: string): number {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < value.length; i += 1) {
+ hash ^= value.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return hash >>> 0;
+}
+
+/** Der Farbton dieser Kennung, in Grad, im Band. */
+export function agentHue(id: string): number {
+ const span = AGENT_HUE_END - AGENT_HUE_START;
+ return AGENT_HUE_START + (hashOf(id) % span);
+}
+
+/** Die Farbe dieser Kennung, als CSS-Farbe. */
+export function agentColor(id: string): string {
+ if (id === YOU_ID) {
+ return YOU_COLOR;
+ }
+ return `hsl(${agentHue(id)} ${AGENT_SATURATION}% ${AGENT_LIGHTNESS}%)`;
+}
+
+/** Die Kandidaten fuer den Buchstaben eines Namens, in der Reihenfolge des Namens. */
+function letterCandidates(name: string): string[] {
+ const letters = [...name.toUpperCase()].filter((character) => /[A-Z0-9]/.test(character));
+ return letters.length > 0 ? letters : ['?'];
+}
+
+/**
+ * Je Kennung ein Buchstabe, alle verschieden, in der Ordnung der Kennungen.
+ *
+ * Erschoepfen sich die Zeichen eines Namens (zwei Agenten mit demselben Namen),
+ * bekommt der spaetere eine Ziffer. Sie ist haesslich und sie ist ehrlich: zwei
+ * Koerper mit demselben Buchstaben waeren zwei, die man nicht unterscheiden
+ * kann.
+ */
+export function agentLetters(actors: readonly { id: string; name: string }[]):
+Map {
+ const out = new Map();
+ const taken = new Set();
+ // Der Buchstabe des Lesers wird vorab belegt, nicht in der Reihe vergeben:
+ // sonst haette ein Agent namens "Yara" das Y, und der Leser bekaeme ein A.
+ if (actors.some((actor) => actor.id === YOU_ID)) {
+ taken.add(YOU_LETTER);
+ }
+ const ordered = [...actors].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+ for (const actor of ordered) {
+ if (actor.id === YOU_ID) {
+ out.set(actor.id, YOU_LETTER);
+ taken.add(YOU_LETTER);
+ continue;
+ }
+ const candidate = letterCandidates(actor.name).find((letter) => !taken.has(letter));
+ if (candidate !== undefined) {
+ out.set(actor.id, candidate);
+ taken.add(candidate);
+ continue;
+ }
+ let digit = 2;
+ while (taken.has(String(digit)) && digit < 10) {
+ digit += 1;
+ }
+ out.set(actor.id, String(digit));
+ taken.add(String(digit));
+ }
+ return out;
+}
diff --git a/graph-ui/src/agents/agent-event.test.ts b/graph-ui/src/agents/agent-event.test.ts
new file mode 100644
index 000000000..b71e6ffa0
--- /dev/null
+++ b/graph-ui/src/agents/agent-event.test.ts
@@ -0,0 +1,136 @@
+/*
+ * Zwei Fragen an das Ereignismodell, und beide sind Ehrlichkeitsfragen.
+ *
+ * **Was liest es, und was erfindet es dabei?** Eine fremde JSON-Zeile darf
+ * nachsichtig gelesen werden; sie darf nicht ergaenzt werden. Ein fehlender
+ * Pfad bleibt leer, ein fehlender Bereich fehlt, eine fehlende Absicht ist
+ * keine.
+ *
+ * **Woran erkennt es die Art der Arbeit?** Am Werkzeugnamen, und fuer den
+ * Testlauf am Befehl. Alles andere waere eine Deutung des Befehls, und die
+ * einzige Deutung, die dieses Modul sich erlaubt, ist die an einem Programm,
+ * das nur eines tut.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ READ_TOOLS,
+ WORK_KIND_LETTERS,
+ eventKey,
+ looksLikeTestRun,
+ readAgentEvent,
+ readLineSpan,
+ sinceParameter,
+ workKindOf,
+} from './agent-event';
+
+const LINE = {
+ ts: 1788038561000,
+ agent: 'implementer',
+ run: 'a1f2c3d4',
+ seq: 3,
+ phase: 'end',
+ tool: 'Edit',
+ path: 'src/services/userService.ts',
+ lines: [24, 30],
+ detail: 'replaced the validation branch',
+};
+
+describe('readAgentEvent', () => {
+ it('liest die Felder des Formats, so wie die Bruecke sie schickt', () => {
+ const event = readAgentEvent(LINE);
+ expect(event).toBeDefined();
+ expect(event?.agent).toBe('implementer');
+ expect(event?.run).toBe('a1f2c3d4');
+ expect(event?.seq).toBe(3);
+ expect(event?.tool).toBe('Edit');
+ expect(event?.path).toBe('src/services/userService.ts');
+ expect(event?.lines).toEqual([24, 30]);
+ expect(event?.replay).toBe(false);
+ });
+
+ it('nimmt eine Zeile mit unbekannten Feldern und uebergeht sie', () => {
+ const event = readAgentEvent({ ...LINE, whatIsThis: 42, source: 'fs' });
+ expect(event).toBeDefined();
+ expect(event?.source).toBe('fs');
+ expect(event).not.toHaveProperty('whatIsThis');
+ });
+
+ it('laesst eine Zeile fallen, der eine der vier Pflichtangaben fehlt', () => {
+ for (const missing of ['ts', 'agent', 'run', 'tool']) {
+ const broken: Record = { ...LINE };
+ delete broken[missing];
+ expect(readAgentEvent(broken), missing).toBeUndefined();
+ }
+ expect(readAgentEvent(null)).toBeUndefined();
+ expect(readAgentEvent('a line')).toBeUndefined();
+ });
+
+ it('erfindet keine Absicht, wo keine steht', () => {
+ expect(readAgentEvent(LINE)?.intent).toBeUndefined();
+ expect(readAgentEvent({ ...LINE, intent: '' })?.intent).toBeUndefined();
+ expect(readAgentEvent({ ...LINE, intent: 'tightening the check' })?.intent)
+ .toBe('tightening the check');
+ });
+
+ it('erfindet keinen halben Zeilenbereich', () => {
+ expect(readLineSpan([24, 30])).toEqual([24, 30]);
+ expect(readLineSpan([30, 24])).toEqual([24, 30]);
+ expect(readLineSpan([24])).toBeUndefined();
+ expect(readLineSpan([24, 30, 40])).toBeUndefined();
+ expect(readLineSpan('24-30')).toBeUndefined();
+ expect(readLineSpan([0, 30])).toBeUndefined();
+ expect(readAgentEvent({ ...LINE, lines: [24] })?.lines).toBeUndefined();
+ });
+
+ it('merkt sich die aufgezeichnete Zeit, wenn die Wiedergabe sie verschoben hat', () => {
+ const event = readAgentEvent({ ...LINE, replay: true, ts_recorded: 1788038000000 });
+ expect(event?.replay).toBe(true);
+ expect(event?.recordedTs).toBe(1788038000000);
+ });
+});
+
+describe('workKindOf', () => {
+ it('liest die vier Arten am Werkzeugnamen ab', () => {
+ expect(workKindOf('Read')).toBe('read');
+ expect(workKindOf('Edit')).toBe('write');
+ expect(workKindOf('Write')).toBe('write');
+ expect(workKindOf('Grep')).toBe('search');
+ expect(workKindOf('Glob')).toBe('search');
+ });
+
+ it('zaehlt das Oeffnen eines Symbols als Lesen', () => {
+ expect(READ_TOOLS).toContain('Open');
+ expect(workKindOf('Open')).toBe('read');
+ });
+
+ it('nennt einen Befehl nur dann einen Testlauf, wenn ein Testprogramm darin steht', () => {
+ expect(workKindOf('Bash', 'npx vitest run test/userService.test.ts')).toBe('test');
+ expect(workKindOf('Bash', 'node --test tests/scaffold/')).toBe('test');
+ expect(workKindOf('Bash', 'npm run test:unit')).toBe('test');
+ // Ein Pfad, in dem "test" vorkommt, ist kein Testlauf.
+ expect(workKindOf('Bash', 'cat test/userService.test.ts')).toBe('other');
+ expect(workKindOf('Bash', 'ls -la')).toBe('other');
+ expect(looksLikeTestRun('pytest -q')).toBe(true);
+ expect(looksLikeTestRun('grep -rn latest src/')).toBe(false);
+ });
+
+ it('sagt bei einem unbekannten Werkzeug, dass es die Art nicht kennt', () => {
+ expect(workKindOf('SomeNewTool')).toBe('other');
+ expect(WORK_KIND_LETTERS.other).toBe('O');
+ expect(new Set(Object.values(WORK_KIND_LETTERS)).size).toBe(5);
+ });
+});
+
+describe('die Wiederaufnahme', () => {
+ it('nennt jede Laufnummer, in einer festen Ordnung', () => {
+ const seen = new Map([['zzz', 4], ['aaa', 9]]);
+ expect(sinceParameter(seen)).toBe('aaa:9,zzz:4');
+ expect(sinceParameter(new Map())).toBe('');
+ });
+
+ it('macht aus Lauf und Nummer eine Identitaet', () => {
+ expect(eventKey(readAgentEvent(LINE)!)).toBe('a1f2c3d4:3');
+ });
+});
diff --git a/graph-ui/src/agents/agent-event.ts b/graph-ui/src/agents/agent-event.ts
new file mode 100644
index 000000000..edd130d22
--- /dev/null
+++ b/graph-ui/src/agents/agent-event.ts
@@ -0,0 +1,234 @@
+/**
+ * Was ein Ereignis ist, und welche Art von Arbeit es zeigt.
+ *
+ * Die Ereignisse kommen aus dem SQLite-Journal desselben Daemons; lokale
+ * Werkzeug-Hooks senden ihre Metadaten an /api/agent-events. Historische
+ * Fixture-Bruecken bleiben separate Testwerkzeuge. Dieses Modul ist die einzige
+ * Stelle, die aus einer fremden JSON-Zeile ein Ereignis dieses Produkts macht,
+ * und es hat dabei genau zwei Aufgaben.
+ *
+ * ## 1. Nachsichtig lesen, ohne zu erfinden
+ *
+ * Eine Zeile, der ein Pflichtfeld fehlt, wird weggelassen. Eine Zeile mit
+ * Feldern, die dieses Produkt nicht kennt, wird genommen und die Felder werden
+ * uebergangen: eine Quelle, die mehr weiss, soll nicht daran scheitern, dass
+ * sie es sagt. Was NICHT passiert, ist Ergaenzen: kein erfundener Pfad, kein
+ * geratener Zeilenbereich, keine Absicht, die niemand geschrieben hat.
+ *
+ * ## 2. Die Art der Arbeit ablesen, nicht deuten
+ *
+ * Vier Arten stehen im Contract (lesen, schreiben, suchen, testen), und sie
+ * werden am WERKZEUGNAMEN abgelesen. Ein Werkzeug, das keiner der vier
+ * zugeordnet ist, bekommt `other` und sagt das auch so: "other" heisst hier
+ * "der Werkzeugname sagt nicht, welche der vier es ist" und nicht "sonstiges".
+ *
+ * Die einzige Ausnahme ist die Kommandozeile. `Bash` ist kein Hinweis auf
+ * irgendetwas, denn darin steckt alles: ein `ls`, ein Testlauf, ein Bau. Fuer
+ * genau einen Fall ist der Befehl aussagekraeftig genug, und das ist der
+ * Testlauf: `vitest`, `pytest`, `node --test` und ihre Geschwister sind
+ * Programme, die nur eines tun. Alles andere an der Kommandozeile bleibt
+ * `other`, weil eine feinere Deutung des Befehls Raten waere.
+ */
+
+/** Die Art der Arbeit, so wie das Instrument sie zeigt. */
+export type WorkKind = 'read' | 'write' | 'search' | 'test' | 'other';
+
+/** Ein Ereignis, wie dieses Produkt es fuehrt. */
+export interface AgentEvent {
+ /** Millisekunden seit 1970. */
+ ts: number;
+ /** Anzeigename des Agenten. */
+ agent: string;
+ /** Kennung des Laufs. Zusammen mit `seq` die Identitaet des Ereignisses. */
+ run: string;
+ /** Fortlaufend je Lauf. Eine Luecke darin ist eine gemeldete Luecke. */
+ seq: number;
+ phase: 'start' | 'end';
+ tool: string;
+ /** Repo-relativer Pfad. Leer heisst: die Zeile nennt keinen. */
+ path: string;
+ /** Der beruehrte Bereich, wenn die Quelle ihn kennt. */
+ lines?: readonly [number, number];
+ /** Der Befehl oder das Suchmuster. Leer heisst: die Zeile nennt keinen. */
+ detail: string;
+ /**
+ * Was der Agent SELBST ueber seine Absicht gesagt hat.
+ *
+ * Optional, und das ist der ganze Punkt: fehlt es, gibt es keine
+ * Absichtszeile. Steht es da, ist es eine Selbstauskunft und wird als
+ * solche gekennzeichnet, nicht als Messung.
+ */
+ intent?: string;
+ /** Woher das Ereignis kommt, wenn die Quelle es sagt (etwa `fs`). */
+ source: string;
+ /** Ob die Quelle dieses Ereignis als Wiedergabe gekennzeichnet hat. */
+ replay: boolean;
+ /** Die aufgezeichnete Zeit, wenn die Wiedergabe sie verschoben hat. */
+ recordedTs?: number;
+}
+
+/**
+ * Die Werkzeuge, die lesen.
+ *
+ * `Open` steht darin, weil die eigene Navigation des Lesers so heisst: ein
+ * geoeffnetes Symbol ist ein gelesenes Symbol, und eine eigene fuenfte Art
+ * dafuer waere eine Art von Arbeit, die es nur fuer einen einzigen Akteur gibt.
+ */
+export const READ_TOOLS: readonly string[] = ['Read', 'ReadFile', 'NotebookRead', 'View', 'Open'];
+
+/** Die Werkzeuge, die schreiben. */
+export const WRITE_TOOLS: readonly string[] = [
+ 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Patch', 'Apply',
+];
+
+/** Die Werkzeuge, die suchen. */
+export const SEARCH_TOOLS: readonly string[] = [
+ 'Grep', 'Glob', 'Search', 'ToolSearch', 'WebSearch', 'Find',
+];
+
+/**
+ * Die Programme, an denen ein Testlauf zu erkennen ist.
+ *
+ * Eine Liste und kein Muster ueber "test", weil das Wort in einem Pfad steht,
+ * ohne dass ein Test laeuft (`cat test/userService.test.ts` liest eine Datei).
+ * Gesucht wird der AUFRUF, also das Programm am Anfang oder hinter einem
+ * Laeufer wie `npx` oder `npm run`.
+ */
+export const TEST_COMMANDS: readonly RegExp[] = [
+ /\bvitest\b/,
+ /\bjest\b/,
+ /\bpytest\b/,
+ /\bmocha\b/,
+ /\bnode\s+--test\b/,
+ /\bnpm\s+(run\s+)?test\b/,
+ /\byarn\s+test\b/,
+ /\bpnpm\s+(run\s+)?test\b/,
+ /\bgo\s+test\b/,
+ /\bcargo\s+test\b/,
+ /\bctest\b/,
+];
+
+/** Der Buchstabe einer Art. Er steht im Instrument neben dem Namen. */
+export const WORK_KIND_LETTERS: Readonly> = {
+ read: 'R',
+ write: 'W',
+ search: 'S',
+ test: 'T',
+ other: 'O',
+};
+
+/** Ob dieser Befehl einen Testlauf startet. */
+export function looksLikeTestRun(command: string): boolean {
+ return TEST_COMMANDS.some((pattern) => pattern.test(command));
+}
+
+/** Die Art der Arbeit, abgelesen am Werkzeugnamen und (nur fuer Tests) am Befehl. */
+export function workKindOf(tool: string, detail = ''): WorkKind {
+ if (READ_TOOLS.includes(tool)) {
+ return 'read';
+ }
+ if (WRITE_TOOLS.includes(tool)) {
+ return 'write';
+ }
+ if (SEARCH_TOOLS.includes(tool)) {
+ return 'search';
+ }
+ if (looksLikeTestRun(detail)) {
+ return 'test';
+ }
+ return 'other';
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function textOf(value: unknown): string {
+ return typeof value === 'string' ? value : '';
+}
+
+function numberOf(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === 'string' && value.trim().length > 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ }
+ return undefined;
+}
+
+/**
+ * Der Zeilenbereich, wenn die Quelle einen nennt.
+ *
+ * Zwei Zahlen, aufsteigend sortiert. Alles andere (eine Zahl, drei Zahlen,
+ * Text) ergibt nichts: ein halber Bereich waere eine Zuordnung, die genauer
+ * aussieht, als sie ist.
+ */
+export function readLineSpan(value: unknown): readonly [number, number] | undefined {
+ if (!Array.isArray(value) || value.length !== 2) {
+ return undefined;
+ }
+ const from = numberOf(value[0]);
+ const to = numberOf(value[1]);
+ if (from === undefined || to === undefined || from < 1 || to < 1) {
+ return undefined;
+ }
+ return from <= to ? [from, to] : [to, from];
+}
+
+/**
+ * Eine Zeile der Bruecke als Ereignis, oder nichts.
+ *
+ * Nichts heisst: `ts`, `agent`, `run` oder `tool` fehlt. Ohne die vier ist ein
+ * Ereignis weder einzuordnen noch zuzuordnen, und ein Platzhalter dafuer waere
+ * ein Koerper auf dem Graphen, der niemandem gehoert.
+ */
+export function readAgentEvent(raw: unknown): AgentEvent | undefined {
+ if (!isRecord(raw)) {
+ return undefined;
+ }
+ const ts = numberOf(raw['ts']);
+ const agent = textOf(raw['agent']);
+ const run = textOf(raw['run']);
+ const tool = textOf(raw['tool']);
+ if (ts === undefined || agent.length === 0 || run.length === 0 || tool.length === 0) {
+ return undefined;
+ }
+ const span = readLineSpan(raw['lines']);
+ const intent = textOf(raw['intent']);
+ const recorded = numberOf(raw['ts_recorded']);
+ return {
+ ts,
+ agent,
+ run,
+ seq: numberOf(raw['seq']) ?? 0,
+ phase: raw['phase'] === 'start' ? 'start' : 'end',
+ tool,
+ path: textOf(raw['path']),
+ ...(span === undefined ? {} : { lines: span }),
+ detail: textOf(raw['detail']),
+ ...(intent.length === 0 ? {} : { intent }),
+ source: textOf(raw['source']),
+ replay: raw['replay'] === true,
+ ...(recorded === undefined ? {} : { recordedTs: recorded }),
+ };
+}
+
+/** Die Identitaet eines Ereignisses: sein Lauf und seine Nummer. */
+export function eventKey(event: AgentEvent): string {
+ return `${event.run}:${event.seq}`;
+}
+
+/**
+ * Die Wiederaufnahme-Angabe fuer die Bruecke.
+ *
+ * `lauf:nummer`, durch Kommas getrennt, in der Ordnung der Laufkennungen, damit
+ * zwei Aufrufe mit demselben Wissen dieselbe Zeichenkette ergeben.
+ */
+export function sinceParameter(seen: ReadonlyMap): string {
+ return [...seen.entries()]
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
+ .map(([run, seq]) => `${run}:${seq}`)
+ .join(',');
+}
diff --git a/graph-ui/src/agents/agent-motion.test.ts b/graph-ui/src/agents/agent-motion.test.ts
new file mode 100644
index 000000000..79f707d09
--- /dev/null
+++ b/graph-ui/src/agents/agent-motion.test.ts
@@ -0,0 +1,217 @@
+/*
+ * Die Rechnung hinter der Bewegung, ohne einen gerenderten Pixel.
+ *
+ * Das BILD prueft der Beweislauf im Browser (tools/smoke-w11b.mjs): dort werden
+ * die Punkte gelesen, die der Koerper wirklich gezeichnet hat. Hier steht, was
+ * die Formeln dahinter zusichern, und das sind fuenf Dinge:
+ *
+ * 1. Die Bahn faengt am Anfang an, endet am Ende und liegt DAZWISCHEN abseits
+ * der geraden Verbindung. Genau das ist die Kruemmung aus AC1.
+ * 2. Derselbe Akteur weicht immer auf dieselbe Seite aus.
+ * 3. Der Puls kommt aus der Zahl der Ereignisse, und ohne Ereignisse gibt es
+ * keinen. Das ist AC3b und zugleich AC6.
+ * 4. Ein Bruch ist eine Folge an DEMSELBEN Knoten in kurzer Folge, und er
+ * ergibt EINE Welle, nicht eine je Ereignis.
+ * 5. Die Feder ist kritisch gedaempft: sie kommt an und schwingt nicht ueber.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ BURST_GAP_MS,
+ DRAWN_BODIES_CAP,
+ IDLE_MS,
+ NO_PULSE,
+ PULSE_BUSY_EVENTS,
+ PULSE_FAST_MS,
+ PULSE_SLOW_MS,
+ TRAIL_NODE_LIMIT,
+ TRANSITION_BEND,
+ TRANSITION_MS,
+ bendSignOf,
+ controlPointOf,
+ currentBursts,
+ distanceFromChord,
+ easeInOutCubic,
+ pulseOf,
+ pulseScaleAt,
+ springStep,
+ transitionPointAt,
+ writeBurstsOf,
+} from './agent-motion';
+import type { BurstInput } from './agent-motion';
+
+const from = { x: 0, y: 0, z: 0 };
+const to = { x: 100, y: 0, z: 0 };
+
+describe('die Bahn', () => {
+ it('faengt am Ausgangspunkt an und endet am Ziel', () => {
+ const control = controlPointOf(from, to, 1);
+ expect(transitionPointAt(from, to, control, 0)).toEqual(from);
+ expect(transitionPointAt(from, to, control, 1)).toEqual(to);
+ });
+
+ it('liegt in der Mitte messbar ABSEITS der geraden Verbindung', () => {
+ const control = controlPointOf(from, to, 1);
+ const middle = transitionPointAt(from, to, control, 0.5);
+ const offset = distanceFromChord(middle, from, to);
+ // Die groesste Abweichung einer quadratischen Bezierkurve von ihrer
+ // Sehne ist die Haelfte des Steuerpunkt-Versatzes.
+ expect(offset).toBeCloseTo((TRANSITION_BEND * 100) / 2, 5);
+ expect(offset / 100).toBeGreaterThan(0.05);
+ });
+
+ it('haelt die Kruemmung als ANTEIL der Strecke, nicht als feste Zahl', () => {
+ const far = { x: 1000, y: 0, z: 0 };
+ const near = { x: 10, y: 0, z: 0 };
+ const farOffset = distanceFromChord(
+ transitionPointAt(from, far, controlPointOf(from, far, 1), 0.5), from, far,
+ );
+ const nearOffset = distanceFromChord(
+ transitionPointAt(from, near, controlPointOf(from, near, 1), 0.5), from, near,
+ );
+ expect(farOffset / 1000).toBeCloseTo(nearOffset / 10, 6);
+ });
+
+ it('weicht fuer dieselbe Kennung immer auf dieselbe Seite aus', () => {
+ expect(bendSignOf(4)).toBe(bendSignOf(4));
+ expect(bendSignOf(4)).toBe(1);
+ expect(bendSignOf(5)).toBe(-1);
+ });
+
+ it('faengt weich an und hoert weich auf', () => {
+ expect(easeInOutCubic(0)).toBe(0);
+ expect(easeInOutCubic(1)).toBe(1);
+ expect(easeInOutCubic(0.5)).toBeCloseTo(0.5, 6);
+ expect(easeInOutCubic(-1)).toBe(0);
+ expect(easeInOutCubic(2)).toBe(1);
+ });
+
+ it('dauert die halbe Sekunde aus dem Contract', () => {
+ expect(TRANSITION_MS).toBeGreaterThanOrEqual(250);
+ expect(TRANSITION_MS).toBeLessThanOrEqual(900);
+ });
+});
+
+describe('der Puls', () => {
+ it('gibt es ohne Ereignisse gar nicht', () => {
+ expect(pulseOf(0)).toEqual(NO_PULSE);
+ expect(pulseOf(-3)).toEqual(NO_PULSE);
+ expect(pulseScaleAt(NO_PULSE, 1234)).toBe(1);
+ expect(pulseScaleAt(NO_PULSE, 99999)).toBe(1);
+ });
+
+ it('schlaegt bei viel Arbeit schnell und kraeftig, bei wenig langsam und schwach', () => {
+ const busy = pulseOf(PULSE_BUSY_EVENTS);
+ const quiet = pulseOf(1);
+ expect(busy.periodMs).toBe(PULSE_FAST_MS);
+ expect(quiet.periodMs).toBe(PULSE_SLOW_MS);
+ expect(busy.periodMs).toBeLessThan(quiet.periodMs);
+ expect(busy.amplitude).toBeGreaterThan(quiet.amplitude);
+ });
+
+ it('laeuft zwischen den beiden Enden monoton', () => {
+ const periods = [1, 3, 6, 9, 12].map((count) => pulseOf(count).periodMs);
+ expect(periods).toEqual([...periods].sort((a, b) => b - a));
+ });
+
+ it('bleibt ueber der Ruhegroesse und geht nie darunter', () => {
+ const pulse = pulseOf(8);
+ const sizes = Array.from({ length: 24 }, (_, i) => pulseScaleAt(pulse, i * 50));
+ expect(Math.min(...sizes)).toBeGreaterThanOrEqual(1);
+ expect(Math.max(...sizes)).toBeGreaterThan(1.1);
+ expect(pulseScaleAt(pulse, 0)).toBeCloseTo(1, 6);
+ });
+
+ it('haelt die Ruhegrenze bei einer Minute', () => {
+ expect(IDLE_MS).toBe(60000);
+ });
+});
+
+describe('der Schreib-Bruch', () => {
+ const write = (ts: number, nodeId: number): BurstInput =>
+ ({ ts, nodeId, write: true, key: `k${ts}` });
+
+ it('macht aus fuenf Aenderungen in zwei Sekunden EINEN Bruch', () => {
+ const bursts = writeBurstsOf([
+ write(0, 7), write(400, 7), write(900, 7), write(1400, 7), write(1900, 7),
+ ]);
+ expect(bursts).toHaveLength(1);
+ expect(bursts[0]?.events).toBe(5);
+ expect(bursts[0]?.from).toBe(0);
+ expect(bursts[0]?.to).toBe(1900);
+ });
+
+ it('trennt bei einer langen Pause', () => {
+ const bursts = writeBurstsOf([
+ write(0, 7), write(400, 7), write(400 + BURST_GAP_MS + 1, 7),
+ ]);
+ expect(bursts).toHaveLength(2);
+ expect(bursts.map((burst) => burst.events)).toEqual([2, 1]);
+ });
+
+ it('trennt, wenn dazwischen an einer anderen Stelle geschrieben wird', () => {
+ const bursts = writeBurstsOf([write(0, 7), write(100, 9), write(200, 7)]);
+ expect(bursts.map((burst) => burst.nodeId)).toEqual([7, 9, 7]);
+ });
+
+ it('zaehlt nur Schreibereignisse mit einem Ort', () => {
+ const bursts = writeBurstsOf([
+ { ts: 0, nodeId: 7, write: false, key: 'a' },
+ { ts: 10, nodeId: -1, write: true, key: 'b' },
+ ]);
+ expect(bursts).toHaveLength(0);
+ });
+
+ it('traegt die Welle nur, solange der Bruch die laufende Arbeit ist', () => {
+ const bursts = writeBurstsOf([write(0, 7), write(400, 7)]);
+ expect(currentBursts(bursts, 400)).toHaveLength(1);
+ // Der Akteur hat danach etwas anderes getan: die Welle ist weg.
+ expect(currentBursts(bursts, 900)).toHaveLength(0);
+ expect(currentBursts([], 400)).toHaveLength(0);
+ });
+});
+
+describe('die Feder der Kamera', () => {
+ it('kommt an und schwingt nicht ueber', () => {
+ let state = { value: 0, velocity: 0 };
+ let overshoot = 0;
+ for (let i = 0; i < 300; i += 1) {
+ state = springStep(state, 100, 1 / 60);
+ overshoot = Math.max(overshoot, state.value - 100);
+ }
+ expect(state.value).toBeCloseTo(100, 1);
+ expect(overshoot).toBeLessThanOrEqual(0);
+ });
+
+ it('faehrt monoton auf das Ziel zu', () => {
+ let state = { value: 0, velocity: 0 };
+ const path: number[] = [];
+ for (let i = 0; i < 120; i += 1) {
+ state = springStep(state, 100, 1 / 60);
+ path.push(state.value);
+ }
+ expect(path).toEqual([...path].sort((a, b) => a - b));
+ });
+
+ it('deckelt einen sehr langen Zeitschritt, statt in einem Sprung anzukommen', () => {
+ const jump = springStep({ value: 0, velocity: 0 }, 100, 5);
+ expect(jump.value).toBeLessThan(100);
+ });
+
+ it('bewegt sich ohne Zeit gar nicht', () => {
+ const state = { value: 3, velocity: 7 };
+ expect(springStep(state, 100, 0)).toBe(state);
+ });
+});
+
+describe('die Deckel', () => {
+ it('nennt die Spur zwischen sechs und zehn Knoten', () => {
+ expect(TRAIL_NODE_LIMIT).toBe(10);
+ expect(TRAIL_NODE_LIMIT).toBeGreaterThanOrEqual(6);
+ });
+
+ it('deckelt die gezeichneten Koerper', () => {
+ expect(DRAWN_BODIES_CAP).toBeGreaterThan(0);
+ });
+});
diff --git a/graph-ui/src/agents/agent-motion.ts b/graph-ui/src/agents/agent-motion.ts
new file mode 100644
index 000000000..5849727a1
--- /dev/null
+++ b/graph-ui/src/agents/agent-motion.ts
@@ -0,0 +1,402 @@
+/**
+ * Die Bewegung: wie ein Koerper von einem Symbol zum naechsten kommt, wie er
+ * atmet, und was hinter ihm stehen bleibt.
+ *
+ * Alles hier ist reine Rechnung ohne Zustand und ohne DOM. Es steht in einer
+ * eigenen Datei, weil genau diese Zahlen im Beweislauf noch einmal
+ * nachgerechnet werden: eine Bahn, die nur in einem `useFrame` existiert, ist
+ * nicht pruefbar, und eine zweite Formel daneben waere eine zweite Wahrheit
+ * ueber dieselbe Bewegung.
+ *
+ * ## Die eine Regel, die diese Datei zusammenhaelt
+ *
+ * **Bewegung entsteht aus Ereignissen, nie aus der Uhr allein.** Ein Ortswechsel
+ * ist die Folge eines Ereignisses, das einen anderen Knoten nennt. Der Puls ist
+ * die Zahl der Ereignisse der letzten Minute. Eine Welle ist ein Schreib-Bruch.
+ * Eine Spur sind besuchte Knoten. Kommt nichts mehr, hoert alles auf: der
+ * Koerper steht, blass, und das ist die Auskunft "hier arbeitet gerade niemand"
+ * statt einer Leerlauf-Animation, die Arbeit behauptet, die nicht stattfindet.
+ *
+ * ## Warum der Uebergang gebogen ist
+ *
+ * Weil man an einer Geraden nicht sieht, ob sie eine Bewegung war. Zwei Koerper,
+ * die zwischen denselben zwei Symbolen hin und her springen, zeichnen dieselbe
+ * Strecke; auf einer gebogenen Bahn sieht man die Richtung, in die geflogen
+ * wurde, und der kurze Schweif dahinter sagt, wie schnell. Die Kruemmung ist
+ * darum keine Verzierung, sondern das, was den Weg lesbar macht: der Beweislauf
+ * misst sie als Abstand der Bahnmitte von der geraden Verbindung.
+ */
+
+/** Ein Punkt im Raum der Szene. */
+export interface MotionPoint {
+ x: number;
+ y: number;
+ z: number;
+}
+
+/**
+ * Wie lange ein Ortswechsel dauert, in Millisekunden.
+ *
+ * Die Zahl kommt aus der Gegenrede zum Entwurf (etwa 450 ms) und sie ist eine
+ * Wahl mit einer Begruendung: kuerzer, und der Flug ist ein Zucken, das man
+ * nicht verfolgen kann; laenger, und der Koerper haengt zwischen zwei Symbolen,
+ * waehrend das naechste Ereignis schon da ist. Die Aufzeichnungen dieses
+ * Projekts haben Abstaende ab etwa 700 ms zwischen zwei Ereignissen desselben
+ * Akteurs; 450 ms passen dazwischen, ohne dass zwei Fluege einander einholen.
+ */
+export const TRANSITION_MS = 450;
+
+/**
+ * Wie weit die Bahn von der geraden Verbindung abweicht.
+ *
+ * Als Anteil der Sehnenlaenge und nicht in Welteinheiten: ein fester Bogen
+ * waere bei einem Sprung ueber die halbe Galaxie ein Strich und bei zwei
+ * benachbarten Symbolen eine Schleife. Der Steuerpunkt liegt in der Mitte der
+ * Sehne, senkrecht dazu um `TRANSITION_BEND * Sehnenlaenge` versetzt; die
+ * groesste Abweichung der quadratischen Bezierkurve von der Sehne ist die
+ * Haelfte davon, also 14 Prozent der Sehnenlaenge.
+ */
+export const TRANSITION_BEND = 0.28;
+
+/** Wie viele Punkte der Kometenschweif traegt. */
+export const COMET_TAIL_POINTS = 12;
+
+/** Wie lange der Schweif nach dem Ankommen noch nachlaeuft, in Millisekunden. */
+export const COMET_TAIL_FADE_MS = 220;
+
+/**
+ * Hoechstens so viele Knoten stehen in der Spur eines Akteurs.
+ *
+ * Die Gegenrede nennt sechs bis zehn. Zehn ist der Deckel, sechs ist keine
+ * Untergrenze, die dieses Modul herstellen koennte: wer erst drei Symbole
+ * beruehrt hat, hat drei, und drei erfundene dazu waeren ein Weg, den niemand
+ * gegangen ist.
+ */
+export const TRAIL_NODE_LIMIT = 10;
+
+/** Hoechstens so viele Spursegmente werden insgesamt gezeichnet. */
+export const TRAIL_SEGMENT_CAP = 120;
+
+/** Das Fenster, aus dem der Puls seine Zahl nimmt. */
+export const PULSE_WINDOW_MS = 60000;
+
+/** Ab so vielen Ereignissen im Fenster schlaegt der Puls am schnellsten. */
+export const PULSE_BUSY_EVENTS = 12;
+
+/** Die kuerzeste Pulsdauer, in Millisekunden. Viel Arbeit. */
+export const PULSE_FAST_MS = 700;
+
+/** Die laengste Pulsdauer. Ein einzelnes Ereignis im Fenster. */
+export const PULSE_SLOW_MS = 2600;
+
+/** Der kleinste Ausschlag, als Anteil der Koerpergroesse. */
+export const PULSE_MIN_AMPLITUDE = 0.14;
+
+/** Der groesste Ausschlag. */
+export const PULSE_MAX_AMPLITUDE = 0.55;
+
+/**
+ * Ab wann ein Akteur als ruhig gilt, in Millisekunden.
+ *
+ * Eine Minute, und die Zahl steht so im Contract (AC6). Was danach passiert,
+ * ist der ganze Punkt: der Koerper haelt an, wird blass und rutscht im
+ * Instrument nach unten. Er verschwindet nicht, denn sein Lauf laeuft weiter
+ * (siehe ACTIVE_WINDOW_MS in agent-store.ts), und ein Koerper, der bei der
+ * ersten Pause verschwaende, waere die Behauptung, der Agent sei fertig.
+ */
+export const IDLE_MS = 60000;
+
+/**
+ * Wie weit zwei Schreibereignisse auseinanderliegen duerfen, um ein Bruch zu
+ * sein.
+ *
+ * Ein Schreib-Bruch ist eine Folge von Aenderungen an DEMSELBEN Knoten, bei der
+ * zwischen zwei Aenderungen weniger als diese Zeit liegt. Fuenf Aenderungen in
+ * zwei Sekunden sind damit ein Bruch und ergeben EINE Welle; fuenf Aenderungen
+ * ueber eine Minute verteilt sind fuenf Brueche und ergeben fuenf. Der
+ * Unterschied ist genau der, den ein Leser sieht: einmal arbeitet jemand an
+ * einer Stelle, einmal kommt er immer wieder zurueck.
+ */
+export const BURST_GAP_MS = 2500;
+
+/** Wie lange die Welle eines Bruchs laeuft, in Millisekunden. */
+export const WAVE_LIFETIME_MS = 1600;
+
+/**
+ * Hoechstens so viele Koerper werden gleichzeitig gezeichnet.
+ *
+ * Der Deckel ist eine Zusicherung ueber die Rechenzeit und keine ueber die
+ * Wahrheit: wer nicht gezeichnet wird, steht weiter im Instrument, und das
+ * Instrument sagt, wie viele es sind. Still das Aelteste fallen zu lassen waere
+ * ein Bild, dem man nicht ansieht, dass es unvollstaendig ist.
+ */
+export const DRAWN_BODIES_CAP = 8;
+
+/**
+ * Die Periode der Federdaempfung der FOLLOW-Kamera, in Millisekunden.
+ *
+ * Die Feder ist KRITISCH gedaempft (Daempfungsgrad genau 1). Das ist der
+ * schnellste Anflug, der ohne Ueberschwingen auskommt, und "ohne
+ * Ueberschwingen" ist hier keine Vorliebe: eine Kamera, die ueber ihr Ziel
+ * hinausfaehrt und zurueckkommt, zeigt eine Bewegung, die der Agent nicht
+ * gemacht hat.
+ */
+export const FOLLOW_SPRING_PERIOD_MS = 760;
+
+/* ------------------------------------------------------------- die Bahn --- */
+
+/** Weich anfangen, weich aufhoeren. */
+export function easeInOutCubic(t: number): number {
+ const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t;
+ return clamped < 0.5
+ ? 4 * clamped * clamped * clamped
+ : 1 - Math.pow(-2 * clamped + 2, 3) / 2;
+}
+
+/**
+ * Auf welche Seite dieser Akteur ausweicht.
+ *
+ * Aus der Kennung und nicht aus dem Zufall: zwei Akteure, die denselben Weg
+ * fliegen, sollen ihn nicht uebereinander fliegen, und derselbe Akteur soll
+ * zweimal dieselbe Bahn nehmen. Sonst waere jeder zweite Flug ein anderes Bild
+ * derselben Bewegung.
+ */
+export function bendSignOf(hash: number): number {
+ return hash % 2 === 0 ? 1 : -1;
+}
+
+/**
+ * Der Steuerpunkt der Bahn: die Mitte der Sehne, senkrecht dazu versetzt.
+ *
+ * Senkrecht in der Bildebene (x/y). Die Tiefe wird linear interpoliert: ein
+ * Bogen durch die z-Achse waere in der Ansicht von vorn keine Kruemmung,
+ * sondern ein Koerper, der kurz naeher steht.
+ */
+export function controlPointOf(from: MotionPoint, to: MotionPoint, sign: number): MotionPoint {
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const length = Math.hypot(dx, dy);
+ const midX = (from.x + to.x) / 2;
+ const midY = (from.y + to.y) / 2;
+ const midZ = (from.z + to.z) / 2;
+ if (length === 0) {
+ return { x: midX, y: midY, z: midZ };
+ }
+ const offset = TRANSITION_BEND * length * sign;
+ return {
+ x: midX + (-dy / length) * offset,
+ y: midY + (dx / length) * offset,
+ z: midZ,
+ };
+}
+
+/** Ein Punkt der Bahn, bei `t` zwischen 0 und 1. Quadratische Bezierkurve. */
+export function transitionPointAt(
+ from: MotionPoint,
+ to: MotionPoint,
+ control: MotionPoint,
+ t: number,
+): MotionPoint {
+ const e = t <= 0 ? 0 : t >= 1 ? 1 : t;
+ const inv = 1 - e;
+ const a = inv * inv;
+ const b = 2 * inv * e;
+ const c = e * e;
+ return {
+ x: a * from.x + b * control.x + c * to.x,
+ y: a * from.y + b * control.y + c * to.y,
+ z: a * from.z + b * control.z + c * to.z,
+ };
+}
+
+/**
+ * Der Abstand eines Punktes von der Geraden durch zwei andere, in der
+ * Bildebene.
+ *
+ * Die Zahl, an der der Beweislauf die Kruemmung misst. Sie steht hier, damit
+ * die Ansicht und der Beweis dieselbe Rechnung benutzen.
+ */
+export function distanceFromChord(
+ point: MotionPoint,
+ from: MotionPoint,
+ to: MotionPoint,
+): number {
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const length = Math.hypot(dx, dy);
+ if (length === 0) {
+ return Math.hypot(point.x - from.x, point.y - from.y);
+ }
+ return Math.abs(dy * point.x - dx * point.y + to.x * from.y - to.y * from.x) / length;
+}
+
+/* -------------------------------------------------------------- der Puls --- */
+
+/** Wie ein Koerper atmet. */
+export interface Pulse {
+ /** Die Dauer eines Atemzugs, in Millisekunden. `0` heisst: kein Puls. */
+ periodMs: number;
+ /** Der Ausschlag, als Anteil der Koerpergroesse. `0` heisst: keiner. */
+ amplitude: number;
+ /** Die Zahl, aus der beides kommt. */
+ events: number;
+}
+
+/** Der Puls ohne jede Arbeit dahinter. */
+export const NO_PULSE: Pulse = { periodMs: 0, amplitude: 0, events: 0 };
+
+/**
+ * Der Puls aus der Zahl der Ereignisse im Fenster.
+ *
+ * Null Ereignisse ergeben KEINEN Puls, und das ist die Zusicherung, um die es
+ * geht: der Puls ist die Arbeit und nicht ihr Abbild. Zwischen einem Ereignis
+ * und {@link PULSE_BUSY_EVENTS} laeuft beides linear, weil eine Kurve hier eine
+ * Aussage ueber die Bedeutung von "viel" waere, die niemand gemessen hat.
+ */
+export function pulseOf(events: number): Pulse {
+ if (!Number.isFinite(events) || events <= 0) {
+ return NO_PULSE;
+ }
+ const share = Math.min(1, (events - 1) / Math.max(1, PULSE_BUSY_EVENTS - 1));
+ return {
+ periodMs: Math.round(PULSE_SLOW_MS + (PULSE_FAST_MS - PULSE_SLOW_MS) * share),
+ amplitude: Number(
+ (PULSE_MIN_AMPLITUDE + (PULSE_MAX_AMPLITUDE - PULSE_MIN_AMPLITUDE) * share).toFixed(4),
+ ),
+ events,
+ };
+}
+
+/**
+ * Die Groesse des Koerpers zu dieser Zeit, als Vielfaches seiner Ruhegroesse.
+ *
+ * Ein Kosinus und keine Sprungfunktion: ein Knoten leuchtet gleichmaessig, ein
+ * Agent atmet, und ein Atemzug hat keine Kanten. Ohne Puls kommt genau 1
+ * heraus, bei jedem `elapsed`: ein ruhiger Koerper aendert seine Groesse nicht.
+ */
+export function pulseScaleAt(pulse: Pulse, elapsedMs: number): number {
+ if (pulse.periodMs <= 0 || pulse.amplitude <= 0) {
+ return 1;
+ }
+ const phase = (elapsedMs % pulse.periodMs) / pulse.periodMs;
+ return 1 + pulse.amplitude * (0.5 - 0.5 * Math.cos(phase * Math.PI * 2));
+}
+
+/* ------------------------------------------------------------- die Welle --- */
+
+/** Ein Schreib-Bruch: mehrere Aenderungen an demselben Knoten in kurzer Folge. */
+export interface WriteBurst {
+ /** Die Kennung des Bruchs. Sie bleibt gleich, solange der Bruch waechst. */
+ key: string;
+ nodeId: number;
+ /** Die Zeit des ersten Ereignisses des Bruchs. */
+ from: number;
+ /** Die Zeit des letzten. */
+ to: number;
+ /** Wie viele Ereignisse darin liegen. */
+ events: number;
+}
+
+/** Ein Ereignis, so weit dieses Modul es braucht. */
+export interface BurstInput {
+ ts: number;
+ nodeId: number;
+ /** Ob es ein Schreibereignis war. Nur die zaehlen. */
+ write: boolean;
+ /** Lauf und Nummer, fuer die Kennung des Bruchs. */
+ key: string;
+}
+
+/**
+ * Die Schreib-Brueche einer Ereignisfolge, aelteste zuerst.
+ *
+ * Ein Bruch endet, wenn das naechste Schreibereignis desselben Knotens mehr als
+ * {@link BURST_GAP_MS} spaeter kommt, oder wenn dazwischen an einem ANDEREN
+ * Knoten geschrieben wurde. Der zweite Fall ist wichtiger, als er aussieht: wer
+ * zwischen zwei Dateien hin und her aendert, arbeitet an zwei Stellen, und
+ * beide sollen ihre eigene Welle bekommen.
+ */
+export function writeBurstsOf(events: readonly BurstInput[]): WriteBurst[] {
+ const bursts: WriteBurst[] = [];
+ let open: WriteBurst | undefined;
+ for (const event of events) {
+ if (!event.write || event.nodeId < 0) {
+ continue;
+ }
+ if (open !== undefined && open.nodeId === event.nodeId
+ && event.ts - open.to <= BURST_GAP_MS) {
+ open.to = event.ts;
+ open.events += 1;
+ continue;
+ }
+ open = {
+ key: event.key,
+ nodeId: event.nodeId,
+ from: event.ts,
+ to: event.ts,
+ events: 1,
+ };
+ bursts.push(open);
+ }
+ return bursts;
+}
+
+/**
+ * Der Bruch, der gerade eine Welle traegt: hoechstens einer, und nur der
+ * laufende.
+ *
+ * "Laufend" heisst: das LETZTE Ereignis dieses Akteurs gehoert zu diesem Bruch.
+ * Er arbeitet also gerade an dieser Stelle. Sobald er etwas anderes tut oder
+ * ruhig wird, ist die Welle weg.
+ *
+ * Es ist ausdruecklich KEINE Frist auf der Wanduhr, und das ist eine
+ * Entscheidung mit einem Grund: die Wiedergabe schiebt die Zeitstempel einer
+ * Aufzeichnung einmal auf die Gegenwart, und danach altern sie mit jeder
+ * Sekunde des Zusehens. Eine Welle, die zwei Sekunden nach dem Ereignis
+ * ausliefe, waere in einer Wiedergabe nie zu sehen, obwohl der Leser den Bruch
+ * genau jetzt vor sich hat. Die Regel "das ist gerade seine Arbeit" gilt in
+ * beiden Betriebsarten gleich.
+ *
+ * Der Rueckgabewert ist eine Liste mit null oder einem Eintrag, weil die
+ * Oberflaeche sie so zeichnet: eine Welle je Bruch, nicht eine je Ereignis.
+ */
+export function currentBursts(
+ bursts: readonly WriteBurst[],
+ lastEventTs: number,
+): WriteBurst[] {
+ const last = bursts[bursts.length - 1];
+ return last !== undefined && last.to === lastEventTs ? [last] : [];
+}
+
+/* ------------------------------------------------------------- die Feder --- */
+
+/** Der Zustand einer Feder auf einer Achse. */
+export interface SpringState {
+ value: number;
+ velocity: number;
+}
+
+/**
+ * Ein Schritt der kritisch gedaempften Feder.
+ *
+ * Halb-implizit integriert (erst die Geschwindigkeit, dann der Ort): das
+ * explizite Verfahren pumpt bei grossen Zeitschritten Energie in die Feder und
+ * schwingt genau da ueber, wo diese Bewegung es nicht darf. Der Zeitschritt
+ * wird ausserdem gedeckelt, damit ein einzelnes sehr langes Bild (ein Reiter,
+ * der zurueckkommt) nicht einen ganzen Anflug in einem Sprung erledigt.
+ */
+export function springStep(
+ state: SpringState,
+ goal: number,
+ deltaSeconds: number,
+ periodMs: number = FOLLOW_SPRING_PERIOD_MS,
+): SpringState {
+ const dt = Math.min(0.1, Math.max(0, deltaSeconds));
+ if (dt === 0 || periodMs <= 0) {
+ return state;
+ }
+ const omega = (2 * Math.PI) / (periodMs / 1000);
+ const velocity = state.velocity
+ + (-2 * omega * state.velocity - omega * omega * (state.value - goal)) * dt;
+ return { value: state.value + velocity * dt, velocity };
+}
diff --git a/graph-ui/src/agents/agent-placement.test.ts b/graph-ui/src/agents/agent-placement.test.ts
new file mode 100644
index 000000000..82adb7545
--- /dev/null
+++ b/graph-ui/src/agents/agent-placement.test.ts
@@ -0,0 +1,182 @@
+/*
+ * Die Zuordnung, an einem Ausschnitt des echten Layouts.
+ *
+ * Die Knoten hier sind abgelesen (fixtures/atlas-sample, indiziert, `/api/layout`)
+ * und nicht erfunden: der Modulknoten umspannt die ganze Datei, der Dateiknoten
+ * traegt gar keine Zeilen, der Ordnerknoten auch nicht. Genau diese drei Formen
+ * sind der Grund, warum die Zuordnung drei Antworten kennt.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import type { GraphNode } from '../galaxy/types';
+import { readAgentEvent } from './agent-event';
+import type { AgentEvent } from './agent-event';
+import {
+ buildPlacementIndex,
+ ghostsFor,
+ normalizePath,
+ placeEvent,
+ testedPathOf,
+} from './agent-placement';
+
+const node = (over: Partial & { id: number }): GraphNode => ({
+ x: 0, y: 0, z: 0, label: 'Function', name: 'x', size: 1, color: '#ffffff', ...over,
+});
+
+const NODES: GraphNode[] = [
+ node({
+ id: 50, label: 'Module', name: 'src/services/userService.ts',
+ file_path: 'src/services/userService.ts', start_line: 1, end_line: 43,
+ qualified_name: 'p.src.services.userService',
+ }),
+ node({
+ id: 51, label: 'Function', name: 'createUser',
+ file_path: 'src/services/userService.ts', start_line: 23, end_line: 36,
+ qualified_name: 'p.src.services.userService.createUser',
+ }),
+ node({
+ id: 52, label: 'Function', name: 'listUsers',
+ file_path: 'src/services/userService.ts', start_line: 18, end_line: 21,
+ qualified_name: 'p.src.services.userService.listUsers',
+ }),
+ node({
+ id: 53, label: 'File', name: 'userService.ts',
+ file_path: 'src/services/userService.ts',
+ qualified_name: 'p.src.services.userService.ts.__file__',
+ }),
+ node({ id: 5, label: 'Folder', name: 'src', file_path: 'src', qualified_name: 'p.src' }),
+ node({
+ id: 60, label: 'Module', name: 'test/userService.test.ts',
+ file_path: 'test/userService.test.ts', start_line: 1, end_line: 17,
+ qualified_name: 'p.test.userService.test',
+ }),
+ node({
+ id: 69, label: 'Function', name: 'validateUser',
+ file_path: 'src/util/validate.ts', start_line: 19, end_line: 31,
+ qualified_name: 'p.src.util.validate.validateUser',
+ }),
+ node({
+ id: 70, label: 'Function', name: 'validateId',
+ file_path: 'src/util/validate.ts', start_line: 33, end_line: 38,
+ qualified_name: 'p.src.util.validate.validateId',
+ }),
+];
+
+const INDEX = buildPlacementIndex(NODES);
+
+const event = (over: Partial): AgentEvent => readAgentEvent({
+ ts: 1, agent: 'a', run: 'r', seq: 1, phase: 'end', tool: 'Read', path: '', detail: '',
+ ...over,
+}) as AgentEvent;
+
+describe('die Zuordnung', () => {
+ it('trifft mit Datei und Zeilen den ENGSTEN Knoten und nicht das Modul', () => {
+ const placed = placeEvent(
+ event({ tool: 'Edit', path: 'src/services/userService.ts', lines: [24, 30] }),
+ 'write',
+ INDEX,
+ );
+ expect(placed.kind).toBe('range');
+ expect(placed.name).toBe('createUser');
+ expect(placed.nodeId).toBe(51);
+ expect(placed.uncertain).toBe(false);
+ });
+
+ it('nimmt bei zwei gleich engen Treffern immer denselben, damit das Bild ruhig bleibt', () => {
+ const twins = buildPlacementIndex([
+ node({ id: 91, name: 'b', file_path: 'a.ts', start_line: 1, end_line: 5 }),
+ node({ id: 90, name: 'a', file_path: 'a.ts', start_line: 1, end_line: 5 }),
+ ]);
+ const placed = placeEvent(event({ path: 'a.ts', lines: [2, 3] }), 'read', twins);
+ expect(placed.nodeId).toBe(90);
+ });
+
+ it('trifft ohne Zeilen den Modulknoten', () => {
+ const placed = placeEvent(
+ event({ path: 'src/services/userService.ts' }),
+ 'read',
+ INDEX,
+ );
+ expect(placed.kind).toBe('file');
+ expect(placed.nodeId).toBe(50);
+ expect(placed.name).toBe('src/services/userService.ts');
+ expect(placed.uncertain).toBe(false);
+ });
+
+ it('kennzeichnet einen Knoten ohne Endzeile als unsicher, statt ihn als Treffer zu verkaufen', () => {
+ const placed = placeEvent(event({ tool: 'Grep', path: 'src', detail: 'validate' }), 'search', INDEX);
+ expect(placed.kind).toBe('file');
+ expect(placed.nodeId).toBe(5);
+ expect(placed.uncertain).toBe(true);
+ });
+
+ it('faellt auf die Datei zurueck, wenn kein Bereich passt, und sagt es nicht anders', () => {
+ const placed = placeEvent(
+ event({ path: 'src/services/userService.ts', lines: [200, 210] }),
+ 'read',
+ INDEX,
+ );
+ expect(placed.kind).toBe('file');
+ expect(placed.nodeId).toBe(50);
+ });
+
+ it('laesst nichts verschwinden, was der Index nicht kennt', () => {
+ const placed = placeEvent(event({ path: 'package.json' }), 'read', INDEX);
+ expect(placed.kind).toBe('none');
+ expect(placed.nodeId).toBeUndefined();
+ expect(placed.why).toBe('the index has no node for this path');
+
+ const noPath = placeEvent(event({ tool: 'Bash', detail: 'ls -la' }), 'other', INDEX);
+ expect(noPath.kind).toBe('none');
+ expect(noPath.why).toBe('the event names no path');
+ });
+
+ it('pingt beim Suchen die Knoten, deren NAME das Muster traegt, und keine anderen', () => {
+ const placed = placeEvent(
+ event({ tool: 'Grep', path: 'src', detail: 'validate' }),
+ 'search',
+ INDEX,
+ );
+ expect(placed.ghostIds).toContain(69);
+ expect(placed.ghostIds).toContain(70);
+ expect(placed.ghostIds).not.toContain(51);
+ // Ein Muster von einem Zeichen ergibt nichts: das waere jeder Knoten.
+ expect(ghostsFor('v', INDEX)).toEqual([]);
+ expect(ghostsFor('', INDEX)).toEqual([]);
+ });
+
+ it('zieht einen Testlauf zu der Datei, die sein Befehl nennt, und sonst nirgendwohin', () => {
+ const placed = placeEvent(
+ event({ tool: 'Bash', detail: 'npx vitest run test/userService.test.ts' }),
+ 'test',
+ INDEX,
+ );
+ expect(placed.testedNodeId).toBe(60);
+ expect(placed.nodeId).toBe(60);
+
+ const blind = placeEvent(
+ event({ tool: 'Bash', detail: 'npm run test:unit' }),
+ 'test',
+ INDEX,
+ );
+ expect(blind.kind).toBe('none');
+ expect(blind.testedNodeId).toBeUndefined();
+ expect(blind.why).toBe('the command names no file this index knows');
+ });
+
+ it('liest aus einem Befehl nur einen Pfad, den der Index wirklich fuehrt', () => {
+ expect(testedPathOf('npx vitest run test/userService.test.ts', INDEX))
+ .toBe('test/userService.test.ts');
+ expect(testedPathOf('npx vitest run test/other.test.ts', INDEX)).toBe('');
+ expect(testedPathOf('vitest ./src/services/userService.ts --run', INDEX))
+ .toBe('src/services/userService.ts');
+ });
+
+ it('bringt Pfade auf eine Form, bevor es sie vergleicht', () => {
+ expect(normalizePath('./src/config.ts')).toBe('src/config.ts');
+ expect(normalizePath('/src/config.ts')).toBe('src/config.ts');
+ expect(normalizePath('src\\config.ts')).toBe('src/config.ts');
+ expect(normalizePath('src/')).toBe('src');
+ });
+});
diff --git a/graph-ui/src/agents/agent-placement.ts b/graph-ui/src/agents/agent-placement.ts
new file mode 100644
index 000000000..2993cc7de
--- /dev/null
+++ b/graph-ui/src/agents/agent-placement.ts
@@ -0,0 +1,292 @@
+/**
+ * Wo im Graphen ein Ereignis liegt, und wie sicher das ist.
+ *
+ * Ein Ereignis nennt einen Pfad und manchmal einen Zeilenbereich. Der Graph
+ * kennt Knoten mit `file_path`, `start_line` und `end_line`. Dieses Modul
+ * bringt beides zusammen und macht dabei drei Unterschiede, die die Oberflaeche
+ * dann auch zeigt:
+ *
+ * 1. **Datei plus Bereich ist genau.** Getroffen werden die Knoten, deren
+ * Bereich sich mit dem des Ereignisses ueberschneidet; bei mehreren gewinnt
+ * der ENGSTE. Der Modulknoten umspannt die ganze Datei und ist damit fast
+ * immer einer der Treffer; er zu gewinnen hiesse, jede Aenderung auf "irgendwo
+ * in dieser Datei" zu runden.
+ * 2. **Nur Datei ist die Datei.** Ohne Bereich gibt es nichts Engeres zu
+ * treffen, also trifft es den Modulknoten. Das ist keine Naeherung, sondern
+ * genau das, was das Ereignis sagt.
+ * 3. **Ein Knoten ohne Endzeile ist unsicher.** Ordner- und Dateiknoten tragen
+ * keinen Bereich. Man kann an ihnen zeigen, wo etwas passiert ist, aber nicht
+ * behaupten, das Ereignis liege in ihrem Bereich, denn sie haben keinen. Der
+ * Treffer wird darum als unsicher gekennzeichnet und nicht als Treffer
+ * verkauft.
+ *
+ * Was gar nicht geht, bleibt sichtbar: `kind: 'none'` traegt den Grund, und das
+ * Instrument zeigt das Rohereignis. Ein Ereignis, das aus der Ansicht faellt,
+ * weil der Index seinen Pfad nicht kennt, waere die stille Behauptung, es habe
+ * nicht stattgefunden.
+ */
+
+import type { GraphNode } from '../galaxy/types';
+import type { AgentEvent, WorkKind } from './agent-event';
+
+/** Wie genau ein Ereignis verortet ist. */
+export type PlacementKind =
+ /** Datei plus Zeilenbereich, auf dem engsten passenden Knoten. */
+ | 'range'
+ /** Nur die Datei; der Knoten ist ihr Modul. */
+ | 'file'
+ /** Nirgends: der Index kennt diesen Pfad nicht, oder es gibt keinen. */
+ | 'none';
+
+/** Wo ein Ereignis liegt. */
+export interface Placement {
+ kind: PlacementKind;
+ /** Die Layout-Kennung des getroffenen Knotens. Fehlt bei `none`. */
+ nodeId?: number;
+ /** Der Name, den das Instrument zeigt. */
+ name: string;
+ /** Der qualifizierte Name, wenn der Knoten einen traegt. */
+ qualifiedName: string;
+ /**
+ * Ob der getroffene Knoten seinen Bereich nicht nennt.
+ *
+ * Dann steht der Ort, aber nicht die Aussage "das Ereignis liegt darin".
+ */
+ uncertain: boolean;
+ /** Warum es nicht weiter ging. Leer, wenn es weiter ging. */
+ why: string;
+ /**
+ * Weitere Knoten, die dasselbe Ereignis beruehrt.
+ *
+ * Nur beim Suchen, und nur solche, deren NAME das Suchmuster enthaelt. Das
+ * ist eine Ablesung am Index und keine Vermutung darueber, was die Suche
+ * wirklich gelesen hat: was ein `grep` an Dateien angefasst hat, weiss
+ * dieses Fenster nicht, und ein Ping an geratenen Stellen waere eine
+ * Behauptung ueber fremde Arbeit.
+ */
+ ghostIds: readonly number[];
+ /**
+ * Der geprueften Bereich eines Testlaufs.
+ *
+ * Gefuellt, wenn der Befehl eine Datei nennt, die der Graph kennt. Fehlt er,
+ * gibt es keine gestrichelte Verbindung, und das Instrument sagt, dass der
+ * Befehl keine bekannte Datei genannt hat.
+ */
+ testedNodeId?: number;
+}
+
+/** Der Index, den dieses Modul braucht: Knoten je Datei und die Namensliste. */
+export interface PlacementIndex {
+ /** Je repo-relativem Pfad die Knoten, die ihn tragen. */
+ byPath: ReadonlyMap;
+ /** Alle Knoten mit einer Datei, fuer die Suche nach Namen. */
+ named: readonly GraphNode[];
+}
+
+/** Der Etikettwert, den der Server einem Modulknoten gibt. */
+export const MODULE_LABEL = 'Module';
+
+/** Hoechstens so viele Ghost-Pings je Suchereignis. */
+export const GHOST_LIMIT = 6;
+
+/** Ein Pfad ohne fuehrende Punkte und Schraegstriche, mit `/` als Trenner. */
+export function normalizePath(path: string): string {
+ let value = path.trim().replace(/\\/g, '/');
+ while (value.startsWith('./')) {
+ value = value.slice(2);
+ }
+ while (value.startsWith('/')) {
+ value = value.slice(1);
+ }
+ while (value.endsWith('/') && value.length > 1) {
+ value = value.slice(0, -1);
+ }
+ return value;
+}
+
+/** Den Index einmal bauen. */
+export function buildPlacementIndex(nodes: readonly GraphNode[]): PlacementIndex {
+ const byPath = new Map();
+ const named: GraphNode[] = [];
+ for (const node of nodes) {
+ const path = normalizePath(node.file_path ?? '');
+ if (path.length === 0) {
+ continue;
+ }
+ const bucket = byPath.get(path);
+ if (bucket === undefined) {
+ byPath.set(path, [node]);
+ } else {
+ bucket.push(node);
+ }
+ named.push(node);
+ }
+ return { byPath, named };
+}
+
+/** Wie viele Zeilen ein Knoten umspannt. Unbekannt heisst: unendlich weit. */
+function spanOf(node: GraphNode): number {
+ if (node.start_line === undefined || node.end_line === undefined) {
+ return Number.POSITIVE_INFINITY;
+ }
+ return node.end_line - node.start_line;
+}
+
+function overlaps(node: GraphNode, from: number, to: number): boolean {
+ if (node.start_line === undefined || node.end_line === undefined) {
+ return false;
+ }
+ return node.start_line <= to && node.end_line >= from;
+}
+
+/** Der Modulknoten dieser Datei, sonst der Knoten, der die Datei am ehesten ist. */
+function fileNodeOf(nodes: readonly GraphNode[]): GraphNode | undefined {
+ const module = nodes.find((node) => node.label === MODULE_LABEL);
+ if (module !== undefined) {
+ return module;
+ }
+ // Sonst der weiteste: ein Datei- oder Ordnerknoten ohne Bereich zaehlt als
+ // unendlich weit und gewinnt damit gegen ein einzelnes Symbol darin.
+ return [...nodes].sort((a, b) => spanOf(b) - spanOf(a))[0];
+}
+
+function displayNameOf(node: GraphNode): string {
+ return node.name.length > 0 ? node.name : node.qualified_name ?? String(node.id);
+}
+
+/** Die Knoten, deren Name das Suchmuster enthaelt. Hoechstens {@link GHOST_LIMIT}. */
+export function ghostsFor(pattern: string, index: PlacementIndex): number[] {
+ const needle = pattern.trim().toLowerCase();
+ if (needle.length < 2) {
+ return [];
+ }
+ const hits: GraphNode[] = [];
+ for (const node of index.named) {
+ if (node.name.toLowerCase().includes(needle)) {
+ hits.push(node);
+ }
+ }
+ return hits
+ .sort((a, b) => a.name.length - b.name.length || a.id - b.id)
+ .slice(0, GHOST_LIMIT)
+ .map((node) => node.id);
+}
+
+/**
+ * Die Datei, die ein Befehl nennt und die der Graph kennt.
+ *
+ * Der Befehl wird in Wortteile zerlegt und jeder gegen den Index gehalten. Kein
+ * Raten an Endungen, kein Teilpfad: entweder der Index kennt genau dieses Wort
+ * als Pfad, oder der Befehl hat keine bekannte Datei genannt.
+ */
+export function testedPathOf(command: string, index: PlacementIndex): string {
+ for (const token of command.split(/[\s'"`;,()]+/)) {
+ const candidate = normalizePath(token);
+ if (candidate.length > 0 && index.byPath.has(candidate)) {
+ return candidate;
+ }
+ }
+ return '';
+}
+
+/** Wo dieses Ereignis liegt. */
+export function placeEvent(
+ event: AgentEvent,
+ kind: WorkKind,
+ index: PlacementIndex,
+): Placement {
+ const empty = { ghostIds: [] as number[] };
+ const path = normalizePath(event.path);
+
+ if (kind === 'test') {
+ const tested = testedPathOf(event.detail, index);
+ if (tested.length > 0) {
+ const node = fileNodeOf(index.byPath.get(tested) ?? []);
+ if (node !== undefined) {
+ return {
+ kind: 'file',
+ nodeId: node.id,
+ name: displayNameOf(node),
+ qualifiedName: node.qualified_name ?? '',
+ uncertain: node.end_line === undefined,
+ why: '',
+ ghostIds: [],
+ testedNodeId: node.id,
+ };
+ }
+ }
+ if (path.length === 0) {
+ return {
+ kind: 'none',
+ name: '',
+ qualifiedName: '',
+ uncertain: false,
+ why: 'the command names no file this index knows',
+ ...empty,
+ };
+ }
+ }
+
+ if (path.length === 0) {
+ return {
+ kind: 'none',
+ name: '',
+ qualifiedName: '',
+ uncertain: false,
+ why: 'the event names no path',
+ ...empty,
+ };
+ }
+
+ const nodes = index.byPath.get(path);
+ if (nodes === undefined || nodes.length === 0) {
+ return {
+ kind: 'none',
+ name: '',
+ qualifiedName: '',
+ uncertain: false,
+ why: 'the index has no node for this path',
+ ...empty,
+ };
+ }
+
+ const ghostIds = kind === 'search' ? ghostsFor(event.detail, index) : [];
+
+ if (event.lines !== undefined) {
+ const [from, to] = event.lines;
+ const hits = nodes.filter((node) => overlaps(node, from, to));
+ if (hits.length > 0) {
+ const best = [...hits].sort((a, b) => spanOf(a) - spanOf(b) || a.id - b.id)[0] as GraphNode;
+ return {
+ kind: 'range',
+ nodeId: best.id,
+ name: displayNameOf(best),
+ qualifiedName: best.qualified_name ?? '',
+ uncertain: false,
+ why: '',
+ ghostIds,
+ };
+ }
+ }
+
+ const node = fileNodeOf(nodes);
+ if (node === undefined) {
+ return {
+ kind: 'none',
+ name: '',
+ qualifiedName: '',
+ uncertain: false,
+ why: 'the index has no node for this path',
+ ghostIds,
+ };
+ }
+ return {
+ kind: 'file',
+ nodeId: node.id,
+ name: displayNameOf(node),
+ qualifiedName: node.qualified_name ?? '',
+ uncertain: node.end_line === undefined,
+ why: '',
+ ghostIds,
+ };
+}
diff --git a/graph-ui/src/agents/agent-preference.test.ts b/graph-ui/src/agents/agent-preference.test.ts
new file mode 100644
index 000000000..43d92f112
--- /dev/null
+++ b/graph-ui/src/agents/agent-preference.test.ts
@@ -0,0 +1,85 @@
+/*
+ * Was gespeichert wird, und was ausdruecklich nicht.
+ *
+ * Die Groesse des Instruments, der Umschalter und die drei Schalter des
+ * laufenden Blicks ueberleben den Reload. Der Live-Modus tut es nicht, und das
+ * ist keine Luecke: eine Seite, die nach dem naechsten Laden von selbst zu
+ * reden anfaengt, waere ein Schalter, ueber den der Leser nur einmal
+ * entschieden hat.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ AGENTS_KEY_PREFIX,
+ DEFAULT_AGENTS_PREFERENCE,
+ agentsKey,
+ clampAgentsPreference,
+ loadAgentsPreference,
+ saveAgentsPreference,
+} from './agent-preference';
+import type { PreferenceStore } from './agent-preference';
+
+function memoryStore(seed: Record = {}): PreferenceStore & { map: Map } {
+ const map = new Map(Object.entries(seed));
+ return {
+ map,
+ getItem: (key) => map.get(key) ?? null,
+ setItem: (key, value) => {
+ map.set(key, value);
+ },
+ };
+}
+
+describe('die Wahl am Instrument', () => {
+ it('startet kompakt, mit beiden Akteursarten und ohne einen einzigen Schalter an', () => {
+ expect(DEFAULT_AGENTS_PREFERENCE).toEqual({
+ size: 'compact',
+ filter: 'both',
+ follow: false,
+ trails: false,
+ fullscreen: false,
+ trailWindowMs: 60000,
+ });
+ });
+
+ it('fuehrt keinen Live-Modus, weil er nicht gespeichert wird', () => {
+ expect(Object.keys(DEFAULT_AGENTS_PREFERENCE)).not.toContain('live');
+ expect(Object.keys(DEFAULT_AGENTS_PREFERENCE)).not.toContain('on');
+ });
+
+ it('haengt am Projekt', () => {
+ expect(agentsKey('demo')).toBe(`${AGENTS_KEY_PREFIX}demo`);
+ });
+
+ it('legt die Wahl ab und liest sie wieder', () => {
+ const store = memoryStore();
+ saveAgentsPreference(store, 'demo', { ...DEFAULT_AGENTS_PREFERENCE, size: 'collapsed' });
+ expect(loadAgentsPreference(store, 'demo').size).toBe('collapsed');
+ });
+
+ it('faellt bei jedem Zweifel auf die Vorgabe zurueck', () => {
+ expect(loadAgentsPreference(memoryStore(), 'demo')).toEqual(DEFAULT_AGENTS_PREFERENCE);
+ expect(loadAgentsPreference(memoryStore({ 'atlas-agents:demo': '{' }), 'demo'))
+ .toEqual(DEFAULT_AGENTS_PREFERENCE);
+ expect(loadAgentsPreference(undefined, 'demo')).toEqual(DEFAULT_AGENTS_PREFERENCE);
+ expect(loadAgentsPreference(memoryStore(), '')).toEqual(DEFAULT_AGENTS_PREFERENCE);
+ });
+
+ it('stutzt einen unbekannten Wert auf die Vorgabe und nicht auf den naechstbesten', () => {
+ const clamped = clampAgentsPreference({
+ size: 'huge', filter: 'nobody', follow: 'yes', trailWindowMs: 7,
+ });
+ expect(clamped).toEqual(DEFAULT_AGENTS_PREFERENCE);
+ });
+
+ it('kostet ein verweigerter Speicher nur das erneute Einstellen', () => {
+ const angry: PreferenceStore = {
+ getItem: () => { throw new Error('denied'); },
+ setItem: () => { throw new Error('denied'); },
+ };
+ expect(loadAgentsPreference(angry, 'demo')).toEqual(DEFAULT_AGENTS_PREFERENCE);
+ expect(saveAgentsPreference(angry, 'demo', DEFAULT_AGENTS_PREFERENCE))
+ .toEqual(DEFAULT_AGENTS_PREFERENCE);
+ });
+});
diff --git a/graph-ui/src/agents/agent-preference.ts b/graph-ui/src/agents/agent-preference.ts
new file mode 100644
index 000000000..69a13145d
--- /dev/null
+++ b/graph-ui/src/agents/agent-preference.ts
@@ -0,0 +1,155 @@
+/**
+ * Was der Leser am Instrument eingestellt hat, und was davon den Reload
+ * ueberlebt.
+ *
+ * Die Groesse ueberlebt ihn, weil der Contract es verlangt und weil es die
+ * richtige Regel ist: wer das Instrument eingeklappt hat, hat entschieden, wie
+ * viel Platz der Graph bekommen soll, und eine Entscheidung, die man bei jedem
+ * Laden neu treffen muss, ist keine Einstellung, sondern eine Geste. Dasselbe
+ * gilt fuer den Umschalter you/agent/both und fuer die drei Schalter des
+ * laufenden Blicks.
+ *
+ * Der Live-Modus selbst steht NICHT hier. Er ist die Frage, ob eine Verbindung
+ * aufgemacht wird, und die Vorgabe darauf ist "nein". Eine gespeicherte
+ * Einschaltung waere eine Seite, die nach dem naechsten Laden von selbst zu
+ * reden anfaengt; die Regel dieses Produkts ist, dass ein Leser das jedes Mal
+ * selbst entscheidet, genau wie beim lokalen Modell.
+ */
+
+import type { ActorFilter, HudSize } from './agent-view';
+import { TRAIL_WINDOWS } from './agent-view';
+
+/** Praefix des Schluessels, unter dem die Wahl eines Projekts liegt. */
+export const AGENTS_KEY_PREFIX = 'atlas-agents:';
+
+/** Der Schluessel dieses Projekts. */
+export function agentsKey(project: string): string {
+ return `${AGENTS_KEY_PREFIX}${project}`;
+}
+
+/**
+ * Was gespeichert wird.
+ *
+ * `fullscreen` hiess bis W11b `cinema`. Umbenannt auf ausdruecklichen
+ * Nutzerwunsch vom 2026-08-30 ("bin jetzt im cinema mode, sollte fullscreen
+ * heissen"), und zwar ueberall: am Schalter, in der Beschriftung, in der
+ * Kommandozeile und an der Naht. Ein gespeicherter Eintrag von vorher traegt
+ * den alten Schluessel; er faellt damit auf die Vorgabe zurueck, und das ist
+ * hier richtig, weil das Vollbild ohnehin bei jedem Laden eine Entscheidung
+ * sein soll.
+ */
+export interface AgentsPreference {
+ size: HudSize;
+ filter: ActorFilter;
+ follow: boolean;
+ trails: boolean;
+ fullscreen: boolean;
+ /**
+ * Das Fenster, ueber das die Wegzeile, die Spur und der Zeitstrahl gehen.
+ *
+ * EINE Zahl fuer alle drei, und nicht drei nebeneinander: sie beantworten
+ * dieselbe Frage ("wie weit zurueck?"), und der Umschalter steht im
+ * Designbild genau einmal, am Zeitstrahl. Drei Fenster, die man einzeln
+ * stellen kann, waeren drei Antworten auf eine Frage.
+ */
+ trailWindowMs: number;
+}
+
+/**
+ * Die Vorgabe.
+ *
+ * Kompakt, beide Akteursarten, und die drei Schalter aus. `follow` aus, weil
+ * eine Kamera, die von selbst losfaehrt, dem Leser die Ansicht nimmt, die er
+ * gerade eingestellt hat; `fullscreen` aus, weil das Vollbild eine Entscheidung
+ * ist; `trails` aus, weil die Wegzeile die Zeilen hoeher macht und das
+ * Instrument im Normalzustand kompakt sein soll.
+ */
+export const DEFAULT_AGENTS_PREFERENCE: AgentsPreference = {
+ size: 'compact',
+ filter: 'both',
+ follow: false,
+ trails: false,
+ fullscreen: false,
+ trailWindowMs: TRAIL_WINDOWS[0] as number,
+};
+
+/** Nur das, was dieses Modul von einem Speicher braucht. */
+export interface PreferenceStore {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+}
+
+function asOneOf(value: unknown, allowed: readonly T[], fallback: T): T {
+ return allowed.includes(value as T) ? (value as T) : fallback;
+}
+
+function asBoolean(value: unknown, fallback: boolean): boolean {
+ return typeof value === 'boolean' ? value : fallback;
+}
+
+/**
+ * Was aus dem Speicher kam, auf zulaessige Werte zurechtstutzen.
+ *
+ * Jeder unbekannte Wert faellt auf die Vorgabe zurueck, wie in
+ * src/galaxy/density.ts und aus demselben Grund: die Vorgabe ist die einzige
+ * Zahl, ueber die dieses Modul etwas weiss.
+ */
+export function clampAgentsPreference(raw: unknown): AgentsPreference {
+ const record = typeof raw === 'object' && raw !== null ? (raw as Record) : {};
+ return {
+ size: asOneOf(
+ record['size'], ['collapsed', 'compact', 'expanded'], DEFAULT_AGENTS_PREFERENCE.size,
+ ),
+ filter: asOneOf(
+ record['filter'], ['you', 'agent', 'both'], DEFAULT_AGENTS_PREFERENCE.filter,
+ ),
+ follow: asBoolean(record['follow'], DEFAULT_AGENTS_PREFERENCE.follow),
+ trails: asBoolean(record['trails'], DEFAULT_AGENTS_PREFERENCE.trails),
+ fullscreen: asBoolean(record['fullscreen'], DEFAULT_AGENTS_PREFERENCE.fullscreen),
+ trailWindowMs: asOneOf(
+ record['trailWindowMs'], TRAIL_WINDOWS, DEFAULT_AGENTS_PREFERENCE.trailWindowMs,
+ ),
+ };
+}
+
+/** Die gespeicherte Wahl dieses Browsers fuer dieses Projekt. */
+export function loadAgentsPreference(
+ store: PreferenceStore | undefined,
+ project: string,
+): AgentsPreference {
+ if (store === undefined || project.length === 0) {
+ return { ...DEFAULT_AGENTS_PREFERENCE };
+ }
+ let raw: string | null;
+ try {
+ raw = store.getItem(agentsKey(project));
+ } catch {
+ return { ...DEFAULT_AGENTS_PREFERENCE };
+ }
+ if (raw === null) {
+ return { ...DEFAULT_AGENTS_PREFERENCE };
+ }
+ try {
+ return clampAgentsPreference(JSON.parse(raw));
+ } catch {
+ return { ...DEFAULT_AGENTS_PREFERENCE };
+ }
+}
+
+/** Die Wahl speichern. Liefert, was jetzt gilt. */
+export function saveAgentsPreference(
+ store: PreferenceStore | undefined,
+ project: string,
+ preference: AgentsPreference,
+): AgentsPreference {
+ if (store === undefined || project.length === 0) {
+ return preference;
+ }
+ try {
+ store.setItem(agentsKey(project), JSON.stringify(preference));
+ } catch {
+ // Ein verweigerter Speicher kostet den Leser das erneute Einstellen beim
+ // naechsten Laden. Dasselbe Abwaegen wie bei den Darstellungswerten.
+ }
+ return preference;
+}
diff --git a/graph-ui/src/agents/agent-source.test.tsx b/graph-ui/src/agents/agent-source.test.tsx
new file mode 100644
index 000000000..67056e2f7
--- /dev/null
+++ b/graph-ui/src/agents/agent-source.test.tsx
@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+import { act, useLayoutEffect } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { POLL_MS, RECONNECT_MS, useAgentStream, type AgentStream } from './agent-source';
+
+let root: Root;
+let container: HTMLDivElement;
+let stream: AgentStream;
+const event = (seq: number) => ({ ts: seq * 1000, agent: 'fixture', run: 'run', seq,
+ phase: 'end', tool: 'Read', path: `file${seq}.ts` });
+const page = (events: unknown[], cursor: number, generation = 'first') => ({
+ ok: true, json: async () => ({ events, cursor, generation, reset: false,
+ retained: events.length, has_more: false, truncated: false }),
+});
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.useRealTimers();
+});
+
+function Consumer(props: { on: boolean; project?: string; fetch: typeof globalThis.fetch }): null {
+ stream = useAgentStream(props);
+ return null;
+}
+
+it('does not connect when off and always polls the current daemon, never a bridge', async () => {
+ const fetch = vi.fn().mockResolvedValue(page([], 0));
+ await act(async () => root.render());
+ await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
+ expect(fetch).not.toHaveBeenCalled();
+ await act(async () => root.render());
+ expect(fetch.mock.calls[0]?.[0]).toBe('/api/agent-events?project=repo%20%2F%20test&after=0&limit=200');
+ expect(stream.status.state).toBe('connected');
+ await act(async () => root.render());
+ await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
+ expect(fetch).toHaveBeenCalledTimes(1);
+});
+
+it('resumes after a connection failure and accepts late events without duplicate counts', async () => {
+ const fetch = vi.fn().mockResolvedValueOnce(page([event(2)], 1))
+ .mockRejectedValueOnce(new Error('connection lost'))
+ .mockResolvedValueOnce(page([event(1), event(2)], 2));
+ await act(async () => root.render());
+ await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); });
+ expect(stream.status.state).toBe('no-source');
+ await act(async () => { await vi.advanceTimersByTimeAsync(RECONNECT_MS); });
+ expect(fetch.mock.calls[1]?.[0]).toContain('after=1');
+ expect(fetch.mock.calls[2]?.[0]).toContain('after=1');
+ expect(stream.state.events).toBe(2);
+ expect(stream.state.actors[0]?.last.seq).toBe(2);
+ expect(stream.status.drops).toBe(1);
+});
+
+it('clears another project or replaced database before accepting its history', async () => {
+ const fetch = vi.fn().mockResolvedValueOnce(page([event(2)], 5))
+ .mockResolvedValueOnce(page([event(1)], 1, 'replacement'))
+ .mockResolvedValueOnce(page([event(1)], 1, 'replacement'))
+ .mockResolvedValueOnce(page([], 0, 'replacement'));
+ await act(async () => root.render());
+ await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS + 1); });
+ expect(fetch.mock.calls[2]?.[0]).toContain('after=0');
+ expect(stream.state.events).toBe(1);
+ expect(stream.state.actors[0]?.last.seq).toBe(1);
+ await act(async () => root.render());
+ expect(fetch.mock.calls[3]?.[0]).toContain('project=two&after=0');
+ expect(stream.state.events).toBe(0);
+});
+
+
+it('hides prior project activity and provenance before passive effects on project change', async () => {
+ const fetch = vi.fn().mockResolvedValueOnce(page([event(1)], 1))
+ .mockImplementation(() => new Promise(() => undefined));
+ const observed: { project: string; events: number; hello: boolean; status: string }[] = [];
+ function Observe({ project }: { project: string }): null {
+ const current = useAgentStream({ on: true, project, fetch });
+ useLayoutEffect(() => {
+ observed.push({ project, events: current.state.events,
+ hello: current.status.hello !== undefined, status: current.status.state });
+ });
+ return null;
+ }
+ await act(async () => root.render());
+ expect(observed.at(-1)).toMatchObject({ project: 'one', events: 1, hello: true, status: 'connected' });
+ await act(async () => root.render());
+ const switched = observed.filter(value => value.project === 'two');
+ expect(switched.length).toBeGreaterThan(0);
+ expect(switched.every(value => value.events === 0 && !value.hello && value.status === 'connecting')).toBe(true);
+ expect(fetch.mock.calls[1]?.[0]).toContain('project=two&after=0');
+});
diff --git a/graph-ui/src/agents/agent-source.ts b/graph-ui/src/agents/agent-source.ts
new file mode 100644
index 000000000..cf34cecff
--- /dev/null
+++ b/graph-ui/src/agents/agent-source.ts
@@ -0,0 +1,272 @@
+/** Same-origin daemon polling over the existing sequential HTTP transport.
+ * Off means zero requests. Cursors are committed only after a validated page;
+ * reconnects retain state, and a replaced SQLite database resets its generation. */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import type { AgentEvent } from './agent-event';
+import { readAgentEvent } from './agent-event';
+import { emptyAgentsState, withEvent } from './agent-store';
+import type { AgentsState } from './agent-store';
+
+/** Kept as a source compatibility name; the current daemon port is used. */
+export const DEFAULT_BRIDGE_PORT = 9749;
+export const RECONNECT_MS = 3000;
+export const POLL_MS = 1000;
+export function bridgeCommand(_port: number): string {
+ return 'codebase-memory-mcp --ui';
+}
+
+/** Die Lage der Quelle. */
+export type SourceState =
+ /** Der Live-Modus ist aus. Es geht keine Anfrage hinaus. */
+ | 'off'
+ /** Eine Verbindung laeuft gerade an. */
+ | 'connecting'
+ /** Der Daemon antwortet mit gespeicherten Ereignissen. */
+ | 'connected'
+ /** Der Modus ist an und niemand antwortet. */
+ | 'no-source';
+
+/** Quelleninformation des Daemons (Feldnamen kompatibel zum bisherigen HUD). */
+export interface BridgeHello {
+ /** `live` liest die lokale SQLite-Historie, `replay` bezeichnet Testaufzeichnungen. */
+ mode: string;
+ /** Lesbare Bezeichnung der Datenquelle. */
+ file: string;
+ /** Wie viele Ereignisse der Daemon aktuell aufbewahrt. */
+ events: number;
+ /** Wie viele empfangene Ereignisse nicht lesbar waren. */
+ unreadable: number;
+}
+
+/** Was die Oberflaeche ueber ihre Quelle weiss. */
+export interface AgentSourceStatus {
+ state: SourceState;
+ /** Der Ursprung, mit dem geredet wird. Leer, solange der Modus aus ist. */
+ origin: string;
+ /** Wie viele Anfragen dieses Modul gestellt hat, seit die Seite laedt. */
+ requests: number;
+ /** Wie oft die Verbindung abgerissen ist. */
+ drops: number;
+ /** Metadaten der zuletzt gelesenen Daemon-Antwort. */
+ hello: BridgeHello | undefined;
+ /** Der letzte Fehler, woertlich. Leer, wenn keiner. */
+ error: string;
+}
+
+/** Ein Rahmen des Ereignisstroms: der Name und der Rumpf. */
+export interface SseFrame {
+ event: string;
+ data: string;
+}
+
+/**
+ * Einen Rahmen lesen.
+ *
+ * Nur die zwei Felder, die dieser Draht braucht. Zeilen, die mit einem
+ * Doppelpunkt beginnen, sind Kommentare (die Bruecke schickt sie, damit die
+ * Leitung offen bleibt) und ergeben nichts.
+ */
+export function parseSseFrame(raw: string): SseFrame | undefined {
+ let name = 'message';
+ const data: string[] = [];
+ for (const line of raw.split('\n')) {
+ if (line.startsWith(':') || line.length === 0) {
+ continue;
+ }
+ if (line.startsWith('event:')) {
+ name = line.slice(6).trim();
+ continue;
+ }
+ if (line.startsWith('data:')) {
+ data.push(line.slice(5).trimStart());
+ }
+ }
+ return data.length === 0 ? undefined : { event: name, data: data.join('\n') };
+}
+
+/** Der aktuelle Daemon-Port; ein alter agents-Parameter hat keine Wirkung. */
+export function bridgePortFromSearch(_search: string): number {
+ return typeof location === 'undefined' ? DEFAULT_BRIDGE_PORT : Number(location.port) || 80;
+}
+
+export interface AgentStreamOptions {
+ /** Ob der Live-Modus an ist. Aus heisst: keine Anfrage. */
+ on: boolean;
+ /** Kompatibilitaetsfeld fuer alte Aufrufer; der Transport verwendet same-origin. */
+ port?: number;
+ project?: string;
+ /** Ersetzbares fetch, damit Tests ohne Netz laufen. */
+ fetch?: typeof globalThis.fetch | undefined;
+}
+
+/** Was der Aufrufer bekommt. */
+export interface AgentStream {
+ state: AgentsState;
+ status: AgentSourceStatus;
+ /** Ein eigenes Browsereignis lokal dazulegen. */
+ push: (event: AgentEvent, you: boolean) => void;
+}
+
+/**
+ * Der Strom, wie ihn die Panels bekommen: samt Port, damit das Instrument den
+ * vorhandenen Daemon-Port anzeigen kann.
+ */
+export interface AgentsRuntime extends AgentStream {
+ port: number;
+ /** Ob der Live-Modus an ist. */
+ on: boolean;
+}
+
+/**
+ * Den Strom fuehren.
+ *
+ * Der Zustand liegt in einem Ref und wird in den React-Zustand gespiegelt: eine
+ * Verbindung, die zwanzig Ereignisse in einer Sekunde liefert, soll nicht
+ * zwanzig Zustandsketten aufmachen, aus denen jede die vorige ueberschreibt.
+ */
+export function useAgentStream(options: AgentStreamOptions): AgentStream {
+ const project = options.project ?? '';
+ const fetchImpl = options.fetch;
+ const on = options.on;
+
+ const stateRef = useRef(emptyAgentsState());
+ const [reading, setReading] = useState({ project, value: stateRef.current });
+ const requests = useRef(0);
+ const drops = useRef(0);
+ const cursor = useRef(0);
+ const incomplete = useRef(false);
+ const generation = useRef(undefined);
+ const previousProject = useRef(project);
+ const [source, setSource] = useState<{ project: string; value: AgentSourceStatus }>({ project, value: {
+ state: 'off',
+ origin: '',
+ requests: 0,
+ drops: 0,
+ hello: undefined,
+ error: '',
+ } });
+
+ const push = useCallback((event: AgentEvent, you: boolean) => {
+ if (previousProject.current !== project) return;
+ const next = withEvent(stateRef.current, event);
+ stateRef.current = you
+ ? {
+ ...next,
+ actors: next.actors.map((actor) =>
+ (actor.id === event.agent ? { ...actor, you: true } : actor)),
+ }
+ : next;
+ setReading({ project, value: stateRef.current });
+ }, [project]);
+
+ useEffect(() => {
+ if (previousProject.current !== project) {
+ previousProject.current = project;
+ cursor.current = 0;
+ generation.current = undefined;
+ incomplete.current = false;
+ stateRef.current = emptyAgentsState();
+ setReading({ project, value: stateRef.current });
+ }
+ if (!on) {
+ setSource({ project, value: {
+ state: 'off',
+ origin: '',
+ requests: requests.current,
+ drops: drops.current,
+ hello: undefined,
+ error: '',
+ } });
+ return;
+ }
+ const origin = typeof location === 'undefined' ? '' : location.origin;
+ const doFetch = fetchImpl ?? globalThis.fetch;
+ let stopped = false;
+ let controller: AbortController | null = null;
+ let timer: ReturnType | undefined;
+
+ const announce = (patch: Partial): void => {
+ setSource((current) => ({ project, value: {
+ ...(current.project === project ? current.value : { state: 'connecting', hello: undefined, error: '' }),
+ origin,
+ requests: requests.current,
+ drops: drops.current,
+ ...patch,
+ } }));
+ };
+
+ const connect = async (): Promise => {
+ if (stopped) {
+ return;
+ }
+ controller = new AbortController();
+ const url = `/api/agent-events?project=${encodeURIComponent(project)}&after=${cursor.current}&limit=200`;
+ requests.current += 1;
+ if (cursor.current === 0) announce({ state: 'connecting', error: '' });
+ try {
+ const response = await doFetch(url, { signal: controller.signal });
+ if (!response.ok) throw new Error(`daemon activity: HTTP ${response.status}`);
+ const page = await response.json() as {
+ events: unknown[]; cursor: number; generation: string; reset: boolean;
+ has_more: boolean; truncated: boolean; retained: number;
+ };
+ if (!Array.isArray(page.events) || !Number.isSafeInteger(page.cursor)
+ || page.cursor < 0 || typeof page.generation !== 'string') {
+ throw new Error('Invalid daemon activity response');
+ }
+ if (stopped) return;
+ if (page.reset || (generation.current !== undefined && generation.current !== page.generation)) {
+ stateRef.current = emptyAgentsState();
+ cursor.current = 0;
+ incomplete.current = false;
+ generation.current = page.generation;
+ setReading({ project, value: stateRef.current });
+ timer = setTimeout(() => void connect(), 0);
+ return;
+ }
+ generation.current = page.generation;
+ let next = stateRef.current;
+ for (const raw of page.events) {
+ const event = readAgentEvent(raw);
+ if (event !== undefined) next = withEvent(next, event);
+ else next = { ...next, unreadable: next.unreadable + 1 };
+ }
+ stateRef.current = next;
+ cursor.current = page.cursor;
+ incomplete.current ||= page.truncated;
+ setReading({ project, value: next });
+ announce({ state: 'connected', error: incomplete.current
+ ? 'Older activity expired from local retention; this history is incomplete.' : '',
+ hello: { mode: 'live', file: 'daemon SQLite activity', events: page.retained, unreadable: next.unreadable } });
+ timer = setTimeout(() => void connect(), page.has_more ? 0 : POLL_MS);
+ } catch (failure) {
+ if (stopped) return;
+ drops.current += 1;
+ announce({ state: 'no-source', error: failure instanceof Error ? failure.message : String(failure) });
+ timer = setTimeout(() => void connect(), RECONNECT_MS);
+ }
+ };
+
+ void connect();
+ return () => {
+ stopped = true;
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ }
+ controller?.abort();
+ };
+ }, [on, project, fetchImpl]);
+
+ // A passive effect resets the cursor, but the first render of another
+ // project must already hide the previous project's events and provenance.
+ return useMemo(() => ({
+ state: reading.project === project ? reading.value : emptyAgentsState(),
+ status: source.project === project ? source.value : {
+ state: on ? 'connecting' as const : 'off' as const, origin: '',
+ requests: requests.current, drops: drops.current, hello: undefined, error: '',
+ },
+ push,
+ }), [reading, source, project, on, push]);
+}
diff --git a/graph-ui/src/agents/agent-store.test.ts b/graph-ui/src/agents/agent-store.test.ts
new file mode 100644
index 000000000..0bd179e6d
--- /dev/null
+++ b/graph-ui/src/agents/agent-store.test.ts
@@ -0,0 +1,174 @@
+/*
+ * Die Buchfuehrung ueber den Strom: Akteure, Reihenfolge, Zahlen.
+ *
+ * Geprueft wird vor allem, was NICHT passiert: keine Luecke, wo die Oberflaeche
+ * spaeter zugehoert hat; keine Zahl, die geschaetzt statt gezaehlt ist; kein
+ * Koerper, der zurueckspringt, weil ein aelteres Ereignis nachgekommen ist.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { readAgentEvent } from './agent-event';
+import type { AgentEvent } from './agent-event';
+import {
+ ACTIVE_WINDOW_MS,
+ activeActors,
+ activityStrip,
+ emptyAgentsState,
+ eventsPerMinute,
+ isActive,
+ recentPaths,
+ timeAtPlace,
+ withEvent,
+ withYouEvent,
+} from './agent-store';
+
+const NOW = 1788040000000;
+
+const event = (over: Partial): AgentEvent => readAgentEvent({
+ ts: NOW, agent: 'implementer', run: 'r1', seq: 1, phase: 'end',
+ tool: 'Edit', path: 'src/a.ts', detail: '', ...over,
+}) as AgentEvent;
+
+const fold = (events: readonly AgentEvent[]) =>
+ events.reduce((state, entry) => withEvent(state, entry), emptyAgentsState());
+
+describe('der Zustand des Stroms', () => {
+ it('fuehrt einen Akteur je Anzeigename und nicht je Lauf', () => {
+ const state = fold([
+ event({ run: 'r1', seq: 1 }),
+ event({ run: 'r2', seq: 1, ts: NOW + 10 }),
+ ]);
+ expect(state.actors).toHaveLength(1);
+ expect(state.actors[0]?.runs).toHaveLength(2);
+ expect(state.actors[0]?.count).toBe(2);
+ });
+
+ it('meldet eine Luecke in der Reihenfolge, und zwar genau die fehlenden', () => {
+ const state = fold([
+ event({ seq: 5 }),
+ event({ seq: 6, ts: NOW + 10 }),
+ event({ seq: 9, ts: NOW + 20 }),
+ ]);
+ expect(state.missed).toBe(2);
+ expect(state.actors[0]?.missed).toBe(2);
+ });
+
+ it('reconciles a late missing sequence and ignores duplicate deliveries', () => {
+ const state = fold([event({ seq: 1 }), event({ seq: 4 }), event({ seq: 2 }),
+ event({ seq: 2 }), event({ seq: 3 })]);
+ expect(state.events).toBe(4);
+ expect(state.missed).toBe(0);
+ expect(state.actors[0]?.missed).toBe(0);
+ });
+
+ it('haelt das erste Ereignis eines Laufs NIE fuer eine Luecke', () => {
+ // Die Oberflaeche ist bei Nummer 40 eingestiegen. Das sind keine
+ // verlorenen Nachrichten, das ist ein spaeter Zuhoerer.
+ const state = fold([event({ seq: 40 }), event({ seq: 41, ts: NOW + 10 })]);
+ expect(state.missed).toBe(0);
+ expect(state.actors[0]?.runs[0]?.joinedAt).toBe(40);
+ });
+
+ it('merkt sich je Lauf die hoechste Nummer, fuer die Wiederaufnahme', () => {
+ const state = fold([
+ event({ run: 'r1', seq: 3 }),
+ event({ run: 'r2', seq: 7, ts: NOW + 1 }),
+ event({ run: 'r1', seq: 2, ts: NOW + 2 }),
+ ]);
+ expect(state.seen.get('r1')).toBe(3);
+ expect(state.seen.get('r2')).toBe(7);
+ });
+
+ it('laesst den Koerper nicht zurueckspringen, wenn ein aelteres Ereignis nachkommt', () => {
+ const state = fold([
+ event({ seq: 2, ts: NOW, path: 'src/new.ts' }),
+ event({ seq: 1, ts: NOW - 5000, path: 'src/old.ts' }),
+ ]);
+ expect(state.actors[0]?.last.path).toBe('src/new.ts');
+ expect(state.actors[0]?.lastTs).toBe(NOW);
+ });
+
+ it('kennzeichnet die eigene Bewegung des Lesers als solche', () => {
+ const state = withYouEvent(emptyAgentsState(), event({ agent: 'you', tool: 'Open' }));
+ expect(state.actors[0]?.you).toBe(true);
+ expect(fold([event({})]).actors[0]?.you).toBe(false);
+ });
+});
+
+describe('die Zahlen des Kopfes', () => {
+ it('zaehlt den Aktivitaetsstreifen aus den Ereignissen und malt nichts dazu', () => {
+ const strip = activityStrip([
+ event({ ts: NOW - 500 }),
+ event({ ts: NOW - 700 }),
+ event({ ts: NOW - 5500 }),
+ event({ ts: NOW - 40000 }), // ausserhalb des Fensters
+ ], NOW, 30);
+ expect(strip).toHaveLength(30);
+ expect(strip[29]).toBe(2);
+ expect(strip[24]).toBe(1);
+ expect(strip.reduce((sum, value) => sum + value, 0)).toBe(3);
+ });
+
+ it('setzt ein Ereignis, das der Uhr voraus ist, in die juengste Sekunde', () => {
+ // Die Uhr des Instruments tickt im Sekundentakt; ein eigenes Ereignis
+ // des Lesers entsteht zwischen zwei Ticks. Es wegzulassen hiesse, "in
+ // den letzten dreissig Sekunden ist nichts passiert" zu zeigen, waehrend
+ // gerade etwas passiert ist.
+ const strip = activityStrip([event({ ts: NOW + 400 })], NOW, 30);
+ expect(strip[29]).toBe(1);
+ expect(strip.reduce((sum, value) => sum + value, 0)).toBe(1);
+ });
+
+ it('laesst weg, was weiter in der Zukunft liegt als der Taktversatz', () => {
+ const strip = activityStrip([event({ ts: NOW + 9000 })], NOW, 30);
+ expect(strip.reduce((sum, value) => sum + value, 0)).toBe(0);
+ });
+
+ it('zaehlt die Ereignisse der letzten Minute und schaetzt sie nicht', () => {
+ const state = fold([
+ event({ seq: 1, ts: NOW - 1000 }),
+ event({ seq: 2, ts: NOW - 30000 }),
+ event({ seq: 3, ts: NOW - 90000 }),
+ ]);
+ expect(eventsPerMinute(state, NOW)).toBe(2);
+ });
+
+ it('rechnet die Verweildauer ueber die zusammenhaengende Kette an EINEM Ort', () => {
+ const state = fold([
+ event({ seq: 1, ts: NOW - 30000, path: 'src/b.ts' }),
+ event({ seq: 2, ts: NOW - 20000, path: 'src/a.ts' }),
+ event({ seq: 3, ts: NOW - 10000, path: 'src/a.ts' }),
+ event({ seq: 4, ts: NOW - 2000, path: 'src/a.ts' }),
+ ]);
+ expect(timeAtPlace(state.actors[0]!, NOW)).toBe(20000);
+ });
+
+ it('zeigt einen Akteur, solange sein letztes Ereignis im Fenster liegt', () => {
+ const fresh = fold([event({ ts: NOW - 1000 })]);
+ const stale = fold([event({ ts: NOW - ACTIVE_WINDOW_MS - 1 })]);
+ expect(isActive(fresh.actors[0]!, NOW)).toBe(true);
+ expect(isActive(stale.actors[0]!, NOW)).toBe(false);
+ expect(activeActors(stale, NOW)).toHaveLength(0);
+ });
+
+ it('sortiert die Akteure nach der juengsten Bewegung', () => {
+ const state = fold([
+ event({ agent: 'a', run: 'ra', ts: NOW - 5000 }),
+ event({ agent: 'b', run: 'rb', ts: NOW - 1000 }),
+ ]);
+ expect(activeActors(state, NOW).map((actor) => actor.id)).toEqual(['b', 'a']);
+ });
+
+ it('baut die Wegzeile aus Pfaden, die wirklich in Ereignissen stehen', () => {
+ const state = fold([
+ event({ seq: 1, ts: NOW - 4000, path: 'src/a.ts' }),
+ event({ seq: 2, ts: NOW - 3000, path: 'src/b.ts' }),
+ event({ seq: 3, ts: NOW - 2000, path: 'src/b.ts' }),
+ event({ seq: 4, ts: NOW - 1000, tool: 'Bash', path: '', detail: 'ls' }),
+ ]);
+ expect(recentPaths(state.actors[0]!, NOW, 0)).toEqual(['src/b.ts', 'src/a.ts']);
+ // Ein Fenster nimmt weg, was aelter ist, und erfindet nichts dazu.
+ expect(recentPaths(state.actors[0]!, NOW, 2500)).toEqual(['src/b.ts']);
+ });
+});
diff --git a/graph-ui/src/agents/agent-store.ts b/graph-ui/src/agents/agent-store.ts
new file mode 100644
index 000000000..4053cb680
--- /dev/null
+++ b/graph-ui/src/agents/agent-store.ts
@@ -0,0 +1,392 @@
+/**
+ * Was aus einem Strom von Ereignissen wird: Akteure, ihre letzten Schritte, und
+ * die Zahlen, die im Kopf des Instruments stehen.
+ *
+ * Alles hier ist eine ZAEHLUNG. Es gibt keinen Fortschritt, keine Bewertung und
+ * keine Vermutung darueber, was ein Agent gerade denkt: was dasteht, ist die
+ * Zahl der Ereignisse, ihre Zeit, ihre Reihenfolge und der Ort, den sie nennen.
+ * Ein Feld, das nicht aus einem Ereignis kommt, gibt es nicht.
+ *
+ * ## Wer ein Akteur ist
+ *
+ * Der Anzeigename. Nicht der Lauf: derselbe Agent, neu gestartet, ist derselbe
+ * Agent mit einem zweiten Lauf, und zwei Koerper fuer ihn waeren zwei Wesen, wo
+ * eines arbeitet. Die Laeufe werden trotzdem einzeln gefuehrt, denn die
+ * Reihenfolge gilt je Lauf.
+ *
+ * ## Wie eine Luecke entsteht
+ *
+ * `seq` zaehlt je Lauf fortlaufend. Kommt nach 6 die 9, fehlen zwei. Das wird
+ * gezaehlt und gemeldet, statt eine lueckenlose Geschichte zu zeichnen.
+ *
+ * Das ERSTE Ereignis eines Laufs zaehlt nie als Luecke, auch wenn es die Nummer
+ * 40 traegt: dann hat die Oberflaeche spaeter zugehoert, und das ist keine
+ * verlorene Nachricht. Was dasteht, ist die Nummer, bei der sie eingestiegen
+ * ist.
+ */
+
+import type { AgentEvent, WorkKind } from './agent-event';
+import { workKindOf } from './agent-event';
+
+/** Wie viele Ereignisse je Akteur behalten werden. */
+export const ACTOR_EVENT_CAP = 80;
+
+/** Wie viele Sekunden der Aktivitaetsstreifen zeigt. */
+export const STRIP_SECONDS = 30;
+
+/** Das Fenster, ueber das die Ereignisrate gerechnet wird. */
+export const RATE_WINDOW_MS = 60000;
+
+/**
+ * Wie weit ein Ereignis der Uhr dieses Fensters vorauslaufen darf.
+ *
+ * Die Uhr des Instruments tickt im Sekundentakt; ein Ereignis, das der Leser
+ * selbst ausloest, entsteht zwischen zwei Ticks und ist dann fuer den Bruchteil
+ * einer Sekunde "in der Zukunft". Zwei Sekunden Spielraum decken das ab, ohne
+ * dass eine wirklich falsche Zeitangabe durchrutscht.
+ */
+export const CLOCK_SLACK_MS = 2000;
+
+/**
+ * Wie lange nach seinem letzten Ereignis ein Akteur noch im Bild steht.
+ *
+ * Drei Minuten, und die Zahl ist eine Abwaegung und keine Messung: kuerzer, und
+ * ein Agent, der eine lange Datei liest, verschwindet mitten in der Arbeit;
+ * laenger, und der Graph traegt Koerper von Agenten, die laengst fertig sind.
+ * Wann ein Akteur zuletzt etwas getan hat, steht in seiner Zeile, damit die
+ * Grenze nachvollziehbar ist statt unsichtbar.
+ */
+export const ACTIVE_WINDOW_MS = 180000;
+
+/** Ein Lauf eines Akteurs, mit seiner Reihenfolge. */
+export interface RunState {
+ run: string;
+ /** Die Nummer, mit der die Oberflaeche in diesen Lauf eingestiegen ist. */
+ joinedAt: number;
+ lastSeq: number;
+ /** Wie viele Nummern zwischen zwei gesehenen Ereignissen fehlten. */
+ missed: number;
+}
+
+/** Ein Akteur, so wie das Instrument ihn zeigt. */
+export interface ActorState {
+ /** Der Anzeigename. Er ist die Identitaet und damit die Quelle der Farbe. */
+ id: string;
+ name: string;
+ /** Ob dieser Akteur die eigene Navigation des Lesers ist. */
+ you: boolean;
+ runs: readonly RunState[];
+ /** Die behaltenen Ereignisse, aeltestes zuerst. */
+ events: readonly AgentEvent[];
+ /** Das letzte Ereignis. Es bestimmt Art und Ort. */
+ last: AgentEvent;
+ kind: WorkKind;
+ /** Wann das erste behaltene Ereignis kam. */
+ firstTs: number;
+ lastTs: number;
+ /** Wie viele Ereignisse dieser Akteur insgesamt geliefert hat. */
+ count: number;
+ /** Wie viele Nummern in seinen Laeufen fehlten. */
+ missed: number;
+}
+
+/** Der ganze Zustand des Stroms. */
+export interface AgentsState {
+ actors: readonly ActorState[];
+ /** Wie viele Ereignisse angekommen sind. */
+ events: number;
+ /** Wie viele fehlten. */
+ missed: number;
+ /** Die zuletzt gesehene Nummer je Lauf, fuer die Wiederaufnahme. */
+ seen: ReadonlyMap;
+ /** Wie viele Zeilen die Bruecke als unlesbar gemeldet hat. */
+ unreadable: number;
+}
+
+/** Der leere Zustand. Kein Akteur, keine Zahl, keine Behauptung. */
+export function emptyAgentsState(): AgentsState {
+ return { actors: [], events: 0, missed: 0, seen: new Map(), unreadable: 0 };
+}
+
+function withRun(runs: readonly RunState[], event: AgentEvent): {
+ runs: RunState[];
+ missed: number;
+} {
+ const found = runs.find((entry) => entry.run === event.run);
+ if (found === undefined) {
+ return {
+ runs: [...runs, { run: event.run, joinedAt: event.seq, lastSeq: event.seq, missed: 0 }],
+ missed: 0,
+ };
+ }
+ const gap = event.seq > found.lastSeq + 1 ? event.seq - found.lastSeq - 1
+ : event.seq > found.joinedAt && event.seq < found.lastSeq && found.missed > 0 ? -1 : 0;
+ return {
+ runs: runs.map((entry) =>
+ (entry.run === event.run
+ ? { ...entry, lastSeq: Math.max(entry.lastSeq, event.seq), missed: entry.missed + gap }
+ : entry)),
+ missed: gap,
+ };
+}
+
+/** Ein Ereignis in den Zustand aufnehmen. Rein, damit ein Test ihn nachbauen kann. */
+export function withEvent(state: AgentsState, event: AgentEvent): AgentsState {
+ // A retry must not increment counts or append another timeline row. Lower
+ // sequence numbers are still accepted when they arrive late.
+ if (state.actors.some((actor) => actor.events.some((seenEvent) =>
+ seenEvent.run === event.run && seenEvent.seq === event.seq))) return state;
+ const id = event.agent;
+ const existing = state.actors.find((actor) => actor.id === id);
+ const seen = new Map(state.seen);
+ const previous = seen.get(event.run);
+ if (previous === undefined || event.seq > previous) {
+ seen.set(event.run, event.seq);
+ }
+
+ if (existing === undefined) {
+ const actor: ActorState = {
+ id,
+ name: id,
+ you: false,
+ runs: [{ run: event.run, joinedAt: event.seq, lastSeq: event.seq, missed: 0 }],
+ events: [event],
+ last: event,
+ kind: workKindOf(event.tool, event.detail),
+ firstTs: event.ts,
+ lastTs: event.ts,
+ count: 1,
+ missed: 0,
+ };
+ return {
+ actors: [...state.actors, actor],
+ events: state.events + 1,
+ missed: state.missed,
+ seen,
+ unreadable: state.unreadable,
+ };
+ }
+
+ const { runs, missed } = withRun(existing.runs, event);
+ const events = [...existing.events, event].slice(-ACTOR_EVENT_CAP);
+ // Das letzte Ereignis ist das mit der spaetesten Zeit und nicht das zuletzt
+ // eingetroffene: bei einer Wiederaufnahme koennen aeltere Ereignisse
+ // nachkommen, und ein Koerper, der dabei zurueckspringt, waere eine
+ // Bewegung, die es nicht gab.
+ const last = event.ts >= existing.lastTs ? event : existing.last;
+ const updated: ActorState = {
+ ...existing,
+ runs,
+ events,
+ last,
+ kind: workKindOf(last.tool, last.detail),
+ firstTs: Math.min(existing.firstTs, event.ts),
+ lastTs: Math.max(existing.lastTs, event.ts),
+ count: existing.count + 1,
+ missed: existing.missed + missed,
+ };
+ return {
+ actors: state.actors.map((actor) => (actor.id === id ? updated : actor)),
+ events: state.events + 1,
+ missed: state.missed + missed,
+ seen,
+ unreadable: state.unreadable,
+ };
+}
+
+/**
+ * Die eigene Bewegung des Lesers aufnehmen.
+ *
+ * Sie laeuft durch dieselbe Ebene und dieselbe Zaehlung, und sie geht NICHT in
+ * die Ereignisdatei: sie entsteht hier, in diesem Fenster, und die Bruecke
+ * kennt nur eine Leserichtung. Der Akteur traegt darum ein eigenes Kennzeichen,
+ * damit der Umschalter ihn von den Agenten trennen kann.
+ */
+export function withYouEvent(state: AgentsState, event: AgentEvent): AgentsState {
+ const next = withEvent(state, event);
+ return {
+ ...next,
+ actors: next.actors.map((actor) =>
+ (actor.id === event.agent ? { ...actor, you: true } : actor)),
+ };
+}
+
+/** Ob dieser Akteur gerade im Bild steht. */
+export function isActive(actor: ActorState, now: number): boolean {
+ return now - actor.lastTs <= ACTIVE_WINDOW_MS;
+}
+
+/** Die Akteure, die gerade im Bild stehen, neueste Bewegung zuerst. */
+export function activeActors(state: AgentsState, now: number): ActorState[] {
+ return state.actors
+ .filter((actor) => isActive(actor, now))
+ .sort((a, b) => b.lastTs - a.lastTs || (a.id < b.id ? -1 : 1));
+}
+
+/**
+ * Der Aktivitaetsstreifen: je Sekunde die Zahl der Ereignisse.
+ *
+ * Aelteste Sekunde zuerst, damit der Streifen von links nach rechts laeuft wie
+ * die Zeit. Ein Balken ohne Ereignis ist eine Null und kein Ziermuster: wo
+ * nichts war, steht nichts.
+ */
+export function activityStrip(
+ events: readonly AgentEvent[],
+ now: number,
+ seconds: number = STRIP_SECONDS,
+): number[] {
+ const bars = new Array(seconds).fill(0);
+ for (const event of events) {
+ const age = now - event.ts;
+ if (age >= seconds * 1000 || age < -CLOCK_SLACK_MS) {
+ continue;
+ }
+ /*
+ * Ein Ereignis, das der Uhr dieses Fensters um Sekundenbruchteile
+ * VORAUS ist, gehoert in die juengste Sekunde und nicht ins Nichts.
+ *
+ * Die Uhr des Instruments tickt im Sekundentakt (AGENT_TICK_MS), und
+ * die eigene Navigation des Lesers erzeugt ihr Ereignis zwischen zwei
+ * Ticks. Es wegzulassen hiesse, einen Streifen zu zeigen, der "in den
+ * letzten dreissig Sekunden ist nichts passiert" sagt, waehrend gerade
+ * etwas passiert ist. Was weiter in der Zukunft liegt als
+ * {@link CLOCK_SLACK_MS}, faellt weiter heraus: das waere kein
+ * Taktversatz mehr, sondern eine Zeitangabe, der dieses Fenster nicht
+ * folgen kann.
+ */
+ const index = age < 0 ? seconds - 1 : seconds - 1 - Math.floor(age / 1000);
+ bars[index] = (bars[index] ?? 0) + 1;
+ }
+ return bars;
+}
+
+/**
+ * Der Zustand, wie er zu einem frueheren Zeitpunkt aussah.
+ *
+ * Fuer den Sprung des Zeitstrahls in die Vergangenheit (W11b AC4). Es wird
+ * NICHTS erfunden und nichts nachgerechnet: die Ereignisse hinter diesem
+ * Zeitpunkt werden weggelassen, und was uebrig bleibt, ist genau das, was die
+ * Ansicht damals gehabt haette. Ein Akteur, dessen Ereignisse alle spaeter
+ * liegen, faellt weg, denn damals war er noch nicht da.
+ *
+ * Die Zaehler `events` und `missed` werden dabei aus den behaltenen Ereignissen
+ * neu gezaehlt und nicht uebernommen: die Zahl im Kopf gehoert zum Bild
+ * darunter, und eine Gesamtzahl aus der Gegenwart ueber einem Bild von damals
+ * waere die stille Behauptung, damals sei schon alles gezaehlt gewesen.
+ */
+export function stateUntil(state: AgentsState, until: number): AgentsState {
+ const actors: ActorState[] = [];
+ let missed = 0;
+ let events = 0;
+ for (const actor of state.actors) {
+ const kept = actor.events.filter((event) => event.ts <= until);
+ if (kept.length === 0) {
+ continue;
+ }
+ const last = kept.reduce((latest, event) => (event.ts >= latest.ts ? event : latest), kept[0] as AgentEvent);
+ let gaps = 0;
+ const bySeq = new Map();
+ for (const event of kept) {
+ const known = bySeq.get(event.run);
+ if (known !== undefined && event.seq > known + 1) {
+ gaps += event.seq - known - 1;
+ }
+ if (known === undefined || event.seq > known) {
+ bySeq.set(event.run, event.seq);
+ }
+ }
+ missed += gaps;
+ events += kept.length;
+ actors.push({
+ ...actor,
+ events: kept,
+ last,
+ kind: workKindOf(last.tool, last.detail),
+ firstTs: Math.min(...kept.map((event) => event.ts)),
+ lastTs: last.ts,
+ count: kept.length,
+ missed: gaps,
+ });
+ }
+ return { actors, events, missed, seen: state.seen, unreadable: state.unreadable };
+}
+
+/** Wie viele Ereignisse dieses Akteurs im Fenster vor `now` liegen. */
+export function eventsInWindow(
+ actor: ActorState,
+ now: number,
+ windowMs: number,
+): number {
+ let count = 0;
+ for (const event of actor.events) {
+ const age = now - event.ts;
+ if (age >= 0 && age < windowMs) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+/** Wie viele Ereignisse in der letzten Minute ankamen. Gezaehlt, nicht geschaetzt. */
+export function eventsPerMinute(state: AgentsState, now: number): number {
+ let count = 0;
+ for (const actor of state.actors) {
+ for (const event of actor.events) {
+ if (now - event.ts < RATE_WINDOW_MS && now - event.ts >= 0) {
+ count += 1;
+ }
+ }
+ }
+ return count;
+}
+
+/**
+ * Wie lange dieser Akteur schon an derselben Stelle ist.
+ *
+ * Gerechnet ueber die zusammenhaengende Kette der letzten Ereignisse mit
+ * demselben Ort: wer viermal hintereinander dieselbe Datei bearbeitet, ist seit
+ * dem ersten dieser vier dort und nicht erst seit dem letzten.
+ */
+export function timeAtPlace(actor: ActorState, now: number): number {
+ const place = (event: AgentEvent): string => `${event.path}`;
+ const current = place(actor.last);
+ let since = actor.last.ts;
+ for (let i = actor.events.length - 1; i >= 0; i -= 1) {
+ const event = actor.events[i] as AgentEvent;
+ if (place(event) !== current) {
+ break;
+ }
+ since = event.ts;
+ }
+ return Math.max(0, now - since);
+}
+
+/**
+ * Die zuletzt beruehrten Pfade, neuester zuerst, ohne Wiederholung.
+ *
+ * Die Wegzeile des Designbildes, und sie besteht aus Ereignissen: jeder Eintrag
+ * ist ein Pfad, den ein Ereignis genannt hat. Was keinen Pfad nennt, steht
+ * nicht darin.
+ */
+export function recentPaths(
+ actor: ActorState,
+ now: number,
+ windowMs: number,
+ limit = 6,
+): string[] {
+ const out: string[] = [];
+ for (let i = actor.events.length - 1; i >= 0; i -= 1) {
+ const event = actor.events[i] as AgentEvent;
+ if (windowMs > 0 && now - event.ts > windowMs) {
+ break;
+ }
+ if (event.path.length === 0 || out.includes(event.path)) {
+ continue;
+ }
+ out.push(event.path);
+ if (out.length >= limit) {
+ break;
+ }
+ }
+ return out;
+}
diff --git a/graph-ui/src/agents/agent-strings.ts b/graph-ui/src/agents/agent-strings.ts
new file mode 100644
index 000000000..cda2011dc
--- /dev/null
+++ b/graph-ui/src/agents/agent-strings.ts
@@ -0,0 +1,270 @@
+/**
+ * Die Saetze des Instruments.
+ *
+ * Sie stehen hier und nicht im Katalog (src/i18n/messages.ts), aus demselben
+ * Grund wie die Saetze der Galaxie und der Aenderungsansicht: der Katalog fuehrt
+ * den Rahmen des Produkts, den jede Flaeche gleich benennt, und ein Fachgebiet
+ * fuehrt seine eigenen Woerter neben sich. Was hier steht, sagt nur diese eine
+ * Ebene.
+ *
+ * Eine Regel gilt fuer jeden Satz darin: **er sagt, was gezaehlt wurde, und
+ * behauptet nichts darueber hinaus.** Kein Fortschritt, keine Prozentzahl, kein
+ * "arbeitet an", kein "denkt nach". Ein Agent, der eine Datei geoeffnet hat, hat
+ * eine Datei geoeffnet.
+ */
+
+import type { WorkKind } from './agent-event';
+
+/** Was ein Buchstabe der Arbeit bedeutet, in einem Satz. */
+export const WORK_KIND_TEXT: Readonly> = {
+ read: 'read a file',
+ write: 'changed a file',
+ search: 'searched',
+ test: 'ran a test command',
+ other: 'ran a command the tool name does not classify',
+};
+
+/** Das kurze Wort, das in der Zeile steht. */
+export const WORK_KIND_WORD: Readonly> = {
+ read: 'reading',
+ write: 'writing',
+ search: 'searching',
+ test: 'testing',
+ other: 'command',
+};
+
+export const agentStrings = {
+ title: 'LIVE AGENTS',
+ panelLabel: 'live agents on the graph',
+
+ /* ------------------------------------------------------------ der Kopf */
+
+ agentCount: (count: number): string =>
+ (count === 1 ? '1 actor' : `${count} actors`),
+ perMinute: (count: number): string =>
+ (count === 1 ? '1 event in the last minute' : `${count} events in the last minute`),
+ orderIntact: 'order intact',
+ orderMissed: (count: number): string =>
+ (count === 1 ? '1 event missed' : `${count} events missed`),
+ orderTitle:
+ 'every run numbers its events. A number that never arrived is counted here; it is not '
+ + 'filled in and not drawn.',
+ joinedAt: (run: string, seq: number): string => `joined run ${run} at number ${seq}`,
+
+ /* ------------------------------------------------------ die drei Lagen */
+
+ foldTitle: (open: boolean): string =>
+ (open ? 'fold the instrument down to one line' : 'unfold the instrument'),
+ fold: (open: boolean): string => (open ? 'fold' : 'unfold'),
+ expand: (expanded: boolean): string => (expanded ? 'compact' : 'expand'),
+ expandTitle: (expanded: boolean): string =>
+ (expanded
+ ? 'back to the compact instrument, so it explains the graph instead of covering it'
+ : 'one card per actor: the path it walked and the events behind every number'),
+ collapsedLine: (count: number): string =>
+ (count === 0
+ ? 'no actor on the graph'
+ : count === 1 ? '1 actor on the graph' : `${count} actors on the graph`),
+
+ /* ------------------------------------------------------- die Quelle */
+
+ sourceOff: 'live mode is off, and nothing is asked',
+ sourceOffDetail:
+ 'not one request goes to the daemon while this is off. Turn it on in the atlas menu with '
+ + '[g] live agents, or type "live agents" in the command line.',
+ sourceConnecting: 'connecting to the daemon',
+ sourceNone: 'live mode is on and no daemon is answering',
+ sourceNoneDetail:
+ 'check that the local daemon is running. It stores tool events in SQLite and serves them '
+ + 'on the same port as this map. Recorded history is retained locally while the connection retries.',
+ sourceConnected: (file: string): string => `reading ${file}`,
+ sourceReplay: (file: string): string => `replaying ${file}`,
+ replayNote:
+ 'these events were recorded earlier. Their spacing is the recorded spacing, shifted forward '
+ + 'so the last one is now.',
+ unreadableLines: (count: number): string =>
+ (count === 1
+ ? '1 line of the file was not readable JSON and was skipped'
+ : `${count} lines of the file were not readable JSON and were skipped`),
+
+ /* --------------------------------------------------------- die Zeilen */
+
+ kindTitle: (kind: WorkKind, letter: string): string => `${letter}: ${WORK_KIND_TEXT[kind]}`,
+ placeRange: (name: string, from: number, to: number): string => `${name}, lines ${from} to ${to}`,
+ placeFile: (name: string): string => name,
+ placeUncertain: (name: string): string => `${name} (no line range in the index)`,
+ placeUncertainTitle:
+ 'the graph knows this node but carries no end line for it, so this says where the event is '
+ + 'and not that it lies inside a range.',
+ placeNone: 'not placeable on the graph',
+ placeNoneTitle: (why: string): string => `${why}. The raw event stands below.`,
+ ago: (seconds: number): string =>
+ (seconds < 60
+ ? `${Math.max(0, Math.round(seconds))}s`
+ : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`),
+ hereFor: (spell: string): string => `here for ${spell}`,
+ lastSeen: (spell: string): string => `last event ${spell} ago`,
+ stripTitle: (seconds: number, count: number): string =>
+ `${count} events in the last ${seconds} seconds, one bar per second, counted from the events `
+ + 'themselves',
+ testedTitle: (name: string): string =>
+ `the command names ${name}, which the index knows. The dashed line goes there.`,
+ testedUnknown: 'the command names no file this index knows, so there is no line to draw',
+
+ /* ------------------------------------------------- die Selbstauskunft */
+
+ intentPrefix: 'agent says:',
+ intentTitle:
+ 'reported by the agent, not measured. This line exists because the event carried an intent '
+ + 'field; without one there is no such line.',
+
+ /* --------------------------------------------------- was nicht passt */
+
+ unmappedTitle: (count: number): string =>
+ (count === 1
+ ? '1 event is not placeable on the graph'
+ : `${count} events are not placeable on the graph`),
+ unmappedIntro:
+ 'they stay here instead of disappearing: an event that falls out of the view would be the '
+ + 'silent claim that it never happened.',
+ unmappedRow: (tool: string, path: string, why: string): string =>
+ `${tool} ${path.length > 0 ? path : '(no path)'}: ${why}`,
+
+ /* ---------------------------------------------------- die Umschalter */
+
+ filterLabel: 'show',
+ filterYou: 'you',
+ filterAgent: 'agents',
+ filterBoth: 'both',
+ filterTitle: (option: string): string =>
+ (option === 'you'
+ ? 'only your own navigation'
+ : option === 'agent' ? 'only the agents' : 'your navigation and the agents'),
+ youName: 'you',
+ youPlace: 'the symbol you opened',
+
+ follow: 'follow',
+ followTitle: (on: boolean): string =>
+ (on
+ ? 'stop following: the camera stays where you put it'
+ : 'the camera flies to the actor that moved last'),
+ trails: 'trails',
+ trailsTitle: (on: boolean): string =>
+ (on
+ ? 'hide the path row of each actor'
+ : 'show the path row: the symbols each actor touched, newest first'),
+ /*
+ * Bis W11b hiess dieser Schalter "cinema". Der Nutzer hat ihn am 2026-08-30
+ * benutzt und woertlich gesagt: "bin jetzt im cinema mode, sollte fullscreen
+ * heissen." Er heisst jetzt so, und zwar an jeder Stelle: Schalter,
+ * Beschriftung, Kommandozeile, Naht und gespeicherte Wahl.
+ */
+ fullscreen: 'fullscreen',
+ fullscreenTitle: (on: boolean): string =>
+ (on
+ ? 'leave the full window and put the graph back into its panel. Escape does the same.'
+ : 'the graph fills the window: bigger bodies, the instrument as a column at the edge, '
+ + 'the timeline underneath.'),
+ fullscreenScope:
+ 'fullscreen is the frame and not a tour: the camera goes where you send it, here as well, '
+ + 'and it is still there when you leave.',
+ windowLabel: 'window',
+ windowOption: (ms: number): string =>
+ (ms === 0 ? 'all kept' : ms < 60000 ? `${ms / 1000}s` : `${ms / 60000}m`),
+ windowTitle: 'how far back the path row, the trail on the graph and the timeline reach',
+ pathEmpty: 'no path yet: no event of this actor named a file',
+
+ /* ------------------------------------------------- die Spur am Graphen */
+
+ trailLegendTitle: 'agent trail',
+ trailLegendDetail:
+ 'the dashed line behind an agent is the path it walked: the last symbols one actor touched, '
+ + 'newest first, drawn under the real edges. It is NOT a relation in the code. The solid '
+ + 'coloured lines come from the index; this one comes from events and fades with the window '
+ + 'you picked.',
+
+ /* -------------------------------------------------------- der Deckel */
+
+ capLine: (drawn: number, total: number): string =>
+ `${drawn} of ${total} bodies drawn: the layer stops at ${drawn}`,
+ capTitle:
+ 'the cap is a promise about computing time, not about the truth: every actor stays in this '
+ + 'list with all its numbers. What is missing is the body on the graph, and this line is '
+ + 'here so the picture is never quietly incomplete.',
+
+ /* -------------------------------------------------------- die Ruhe */
+
+ idleTitle: (seconds: number): string =>
+ `no event for ${Math.round(seconds)}s: this body stands still and stays pale until the next `
+ + 'one. Nothing here is animated while nothing happens.',
+
+ /* --------------------------------------------------------- der Puls */
+
+ pulseTitle: (events: number, periodMs: number): string =>
+ (events === 0
+ ? 'no event in the last minute, so this body does not breathe'
+ : `${events} events in the last minute: one breath every ${(periodMs / 1000).toFixed(1)}s. `
+ + 'The pulse is the count, not decoration.'),
+
+ /* ------------------------------------------------------ die Wiedergabe */
+
+ followLine: (name: string, kind: string, place: string, lines: readonly number[]): string =>
+ `${name} ${kind} ${place.length > 0 ? place : 'nothing this index knows'}`
+ + (lines.length === 2 ? `, lines ${lines[0]} to ${lines[1]}` : ''),
+ followLineTitle:
+ 'the event the camera just followed. Actor, kind of work, symbol and line range, all four '
+ + 'read from the event and the index; nothing else is in this line.',
+
+ /* ------------------------------------------------------ der Zeitstrahl */
+
+ timelineLabel: 'timeline',
+ timelineLive: 'LIVE',
+ timelinePaused: 'PAUSED',
+ timelineReplay: 'REPLAY',
+ timelineLiveTitle: 'the window ends now and moves with it',
+ timelinePauseTitle:
+ 'hold the window where it is. Events keep arriving and are all there when you let it run '
+ + 'again: what stops is the scrolling, not the listening.',
+ timelineResume: 'resume',
+ timelinePause: 'pause',
+ timelineReplayNote: (spell: string): string =>
+ `showing the state of ${spell} ago, not now`,
+ timelineReplayTitle:
+ 'a click on the timeline shows the state at that moment. Everything on the screen belongs to '
+ + 'then, and it says so, because an old state looks exactly like the current one.',
+ timelineBackToLive: 'back to live',
+ timelineTrackTitle: (name: string, count: number): string =>
+ `${name}: ${count === 1 ? '1 event' : `${count} events`} in this window, one tick each`,
+ timelineEmpty: 'no event of this actor in this window',
+ timelineNarrow: (width: number): string =>
+ `the graph area is ${width} px wide; the timeline needs 600 to keep one tick apart from the `
+ + 'next',
+
+ /* ---------------------------------------------------------- der Ticker */
+
+ tickerLabel: 'events',
+ tickerLine: (name: string, kind: string, place: string, lines: readonly number[]): string =>
+ `${name} ${kind} ${place.length > 0 ? place : 'nothing this index knows'}`
+ + (lines.length === 2 ? `, lines ${lines[0]} to ${lines[1]}` : ''),
+ tickerTitle:
+ 'the last events in plain words. One line per event, and every part of it is read from the '
+ + 'event: who, what kind of work, which symbol, which lines.',
+
+ /* ---------------------------------------------------------- die Ebene */
+
+ layerOff: 'the agent layer is off in the settings, so nothing is drawn on the graph',
+} as const;
+
+/**
+ * Das Etikett des Menuepunkts.
+ *
+ * Es traegt den ZUSTAND und nicht die Wirkung, wie `[l]ocal llm off` daneben:
+ * "off" heisst "es ist aus" und nicht "hier ausschalten". Dieselbe Lesart wie
+ * bei [a]tlas, dessen `data-state` ebenfalls die Lage faerbt.
+ *
+ * Der Buchstabe steht in Klammern am Anfang, weil AtlasChrome ihn dort
+ * heraustrennt (splitMenuLabel) und ein Terminal keine Unterstreichung hat.
+ */
+export function agentsMenuLabel(on: boolean): string {
+ return on ? '[g] live agents on' : '[g] live agents off';
+}
diff --git a/graph-ui/src/agents/agent-timeline.test.ts b/graph-ui/src/agents/agent-timeline.test.ts
new file mode 100644
index 000000000..440efefa1
--- /dev/null
+++ b/graph-ui/src/agents/agent-timeline.test.ts
@@ -0,0 +1,117 @@
+/*
+ * Der Zeitstrahl, ohne einen gerenderten Pixel.
+ *
+ * Vier Zusicherungen, und alle vier sind die Ehrlichkeitsregeln dieser Flaeche:
+ *
+ * 1. Ein Strich je Ereignis, keine Glaettung. Was ausserhalb des Fensters
+ * liegt, steht nicht darin, und was darin liegt, steht an der Stelle seiner
+ * Zeit.
+ * 2. Die Pause haelt das Fenster an. Ereignisse laufen weiter ein.
+ * 3. Ein Sprung in die Vergangenheit ist als Wiedergabe gekennzeichnet und
+ * schlaegt eine Pause.
+ * 4. `unbegrenzt` reicht bis zum ersten behaltenen Ereignis.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { TIMELINE_MIN_WIDTH, TIMELINE_WINDOWS, buildTimeline, timeAtFraction } from './agent-timeline';
+import type { TimelineActor } from './agent-timeline';
+
+const NOW = 1788139345000;
+
+const actor = (id: string, offsets: number[]): TimelineActor => ({
+ id,
+ name: id,
+ color: '#ffffff',
+ letter: id.slice(0, 1).toUpperCase(),
+ you: false,
+ idle: false,
+ firstTs: NOW - Math.max(...offsets, 0),
+ events: offsets.map((offset) => ({ ts: NOW - offset, kind: 'read' as const })),
+});
+
+describe('das Fenster', () => {
+ it('kennt die vier Stufen des Designbildes', () => {
+ expect([...TIMELINE_WINDOWS]).toEqual([60000, 300000, 900000, 0]);
+ });
+
+ it('laesst weg, was ausserhalb liegt, und setzt den Rest an seine Zeit', () => {
+ const timeline = buildTimeline({
+ actors: [actor('implementer', [0, 30000, 90000])],
+ now: NOW,
+ windowMs: 60000,
+ });
+ expect(timeline.ticks).toBe(2);
+ const ticks = timeline.tracks[0]?.ticks ?? [];
+ // Die Reihenfolge ist die der behaltenen Ereignisse, und die stehen
+ // hier neuestes zuerst: der Strahl zeichnet sie an ihre Zeit, nicht in
+ // ihrer Reihenfolge.
+ expect([...ticks.map((tick) => Number(tick.at.toFixed(2)))].sort())
+ .toEqual([0.5, 1]);
+ });
+
+ it('reicht bei "alles Behaltene" bis zum ersten Ereignis', () => {
+ const timeline = buildTimeline({
+ actors: [actor('implementer', [0, 30000, 900000])],
+ now: NOW,
+ windowMs: 0,
+ });
+ expect(timeline.windowMs).toBe(900000);
+ expect(timeline.ticks).toBe(3);
+ });
+
+ it('zaehlt eine Spur je Akteur', () => {
+ const timeline = buildTimeline({
+ actors: [actor('a', [0]), actor('b', [1000]), actor('c', [2000])],
+ now: NOW,
+ windowMs: 60000,
+ });
+ expect(timeline.tracks).toHaveLength(3);
+ expect(timeline.tracks.map((track) => track.count)).toEqual([1, 1, 1]);
+ });
+});
+
+describe('die drei Lagen', () => {
+ const actors = [actor('implementer', [0, 20000])];
+
+ it('laeuft mit, solange niemand anhaelt', () => {
+ const timeline = buildTimeline({ actors, now: NOW, windowMs: 60000 });
+ expect(timeline.mode).toBe('live');
+ expect(timeline.to).toBe(NOW);
+ });
+
+ it('haelt das Fenster an, wo der Leser angehalten hat', () => {
+ const timeline = buildTimeline({
+ actors, now: NOW + 5000, windowMs: 60000, pausedAt: NOW,
+ });
+ expect(timeline.mode).toBe('paused');
+ expect(timeline.to).toBe(NOW);
+ });
+
+ it('nennt einen Sprung in die Vergangenheit Wiedergabe und schlaegt die Pause', () => {
+ const timeline = buildTimeline({
+ actors, now: NOW, windowMs: 60000, pausedAt: NOW - 1000, replayAt: NOW - 20000,
+ });
+ expect(timeline.mode).toBe('replay');
+ expect(timeline.to).toBe(NOW - 20000);
+ });
+});
+
+describe('der Klick auf den Strahl', () => {
+ it('rechnet die Stelle in eine Zeit um und bleibt im Fenster', () => {
+ const timeline = buildTimeline({
+ actors: [actor('implementer', [0])], now: NOW, windowMs: 60000,
+ });
+ expect(timeAtFraction(timeline, 0)).toBe(NOW - 60000);
+ expect(timeAtFraction(timeline, 1)).toBe(NOW);
+ expect(timeAtFraction(timeline, 0.5)).toBe(NOW - 30000);
+ expect(timeAtFraction(timeline, -2)).toBe(NOW - 60000);
+ expect(timeAtFraction(timeline, 9)).toBe(NOW);
+ });
+});
+
+describe('die Breite', () => {
+ it('nennt die Grenze, unter der ein Strich nicht mehr vom naechsten zu trennen ist', () => {
+ expect(TIMELINE_MIN_WIDTH).toBeGreaterThanOrEqual(600);
+ });
+});
diff --git a/graph-ui/src/agents/agent-timeline.ts b/graph-ui/src/agents/agent-timeline.ts
new file mode 100644
index 000000000..14bb567d0
--- /dev/null
+++ b/graph-ui/src/agents/agent-timeline.ts
@@ -0,0 +1,164 @@
+/**
+ * Der Zeitstrahl unten: eine Spur je Akteur, ein Strich je Ereignis.
+ *
+ * ## Was er ist
+ *
+ * Eine Ablesung und keine Zusammenfassung. Jeder Strich ist genau ein Ereignis,
+ * an der Stelle, an der seine Zeit im Fenster liegt, in der Farbe seines
+ * Akteurs. Es gibt keine Glaettung, keine Balken je Sekunde und keine Kurve:
+ * eine Kurve waere eine Aussage darueber, wie dicht die Arbeit "eigentlich"
+ * war, und dieses Fenster weiss nur, wann ein Werkzeug fertig war.
+ *
+ * ## Die drei Lagen, und warum sie verschieden heissen
+ *
+ * - **live**: das Fenster endet jetzt und wandert mit.
+ * - **paused**: das Fenster endet dort, wo der Leser angehalten hat. Die
+ * Ereignisse laufen weiter ein und stehen nach dem Fortsetzen da; angehalten
+ * ist das NACHLAUFEN und nicht das Zuhoeren. Ein Pausenknopf, der die Quelle
+ * abhaengt, waere eine Luecke in der Aufzeichnung, die niemand angefordert
+ * hat.
+ * - **replay**: der Leser hat auf eine Stelle geklickt und sieht den Zustand
+ * von damals. Das ist die gefaehrlichste der drei Lagen, denn ein alter
+ * Zustand sieht aus wie der jetzige; sie ist darum die einzige, die die
+ * ganze Ansicht kennzeichnet.
+ */
+
+import type { WorkKind } from './agent-event';
+
+/**
+ * Die Fenster, die der Zeitstrahl kennt. `0` heisst: alles, was behalten wurde.
+ *
+ * Dieselben vier wie im Designbild (60s, 5m, 15m, unbegrenzt).
+ */
+export const TIMELINE_WINDOWS: readonly number[] = [60000, 300000, 900000, 0];
+
+/**
+ * Ab welcher Breite der Zeitstrahl ueberhaupt gezeigt wird, in Pixeln.
+ *
+ * Gemessen und nicht an einen Modus gebunden: in dem 441 Pixel breiten Panel
+ * dieses Layouts waere eine Spur ueber fuenfzehn Minuten ein Strich, in dem
+ * kein Ereignis mehr von seinem Nachbarn zu unterscheiden ist. Sechshundert
+ * Pixel tragen bei fuenfzehn Minuten eine Aufloesung von 1.5 Sekunden je Pixel;
+ * darunter waere die Anzeige eine Behauptung ueber eine Genauigkeit, die das
+ * Bild nicht hat.
+ */
+export const TIMELINE_MIN_WIDTH = 600;
+
+/** Wie viele Zeilen der Ereignis-Ticker im Vollbild traegt. */
+export const TICKER_LIMIT = 6;
+
+/** Ein Strich auf einer Spur. */
+export interface TimelineTick {
+ ts: number;
+ /** Wo er im Fenster liegt, zwischen 0 (Anfang) und 1 (Ende). */
+ at: number;
+ kind: WorkKind;
+}
+
+/** Eine Spur: ein Akteur und seine Striche. */
+export interface TimelineTrack {
+ id: string;
+ name: string;
+ color: string;
+ letter: string;
+ you: boolean;
+ idle: boolean;
+ ticks: readonly TimelineTick[];
+ /** Wie viele Ereignisse dieses Akteurs im Fenster liegen. */
+ count: number;
+}
+
+/** In welcher Lage der Zeitstrahl steht. */
+export type TimelineMode = 'live' | 'paused' | 'replay';
+
+/** Der ganze Zeitstrahl. */
+export interface Timeline {
+ mode: TimelineMode;
+ /** Der Anfang des Fensters, in Millisekunden seit 1970. */
+ from: number;
+ /** Sein Ende. */
+ to: number;
+ windowMs: number;
+ tracks: readonly TimelineTrack[];
+ /** Wie viele Striche insgesamt darauf stehen. */
+ ticks: number;
+}
+
+/** Was ein Akteur zum Zeitstrahl beitraegt. */
+export interface TimelineActor {
+ id: string;
+ name: string;
+ color: string;
+ letter: string;
+ you: boolean;
+ idle: boolean;
+ firstTs: number;
+ events: readonly { ts: number; kind: WorkKind }[];
+}
+
+export interface TimelineInput {
+ actors: readonly TimelineActor[];
+ /** Die Gegenwart. */
+ now: number;
+ windowMs: number;
+ /** Wo der Leser angehalten hat, wenn er angehalten hat. */
+ pausedAt?: number | undefined;
+ /** Wohin der Leser gesprungen ist, wenn er gesprungen ist. */
+ replayAt?: number | undefined;
+}
+
+/**
+ * Den Zeitstrahl bauen.
+ *
+ * Das Ende des Fensters ist die Gegenwart, der Haltepunkt oder der
+ * Sprungpunkt, in dieser Reihenfolge: ein Sprung in die Vergangenheit schlaegt
+ * eine Pause, weil er die staerkere Aussage ist.
+ */
+export function buildTimeline(input: TimelineInput): Timeline {
+ const mode: TimelineMode = input.replayAt !== undefined
+ ? 'replay'
+ : input.pausedAt !== undefined ? 'paused' : 'live';
+ const to = input.replayAt ?? input.pausedAt ?? input.now;
+ const earliest = input.actors.reduce(
+ (min, actor) => Math.min(min, actor.firstTs),
+ Number.POSITIVE_INFINITY,
+ );
+ const span = input.windowMs > 0
+ ? input.windowMs
+ : Math.max(1000, to - (Number.isFinite(earliest) ? earliest : to - 1000));
+ const from = to - span;
+
+ let ticks = 0;
+ const tracks = input.actors.map((actor) => {
+ const own: TimelineTick[] = [];
+ for (const event of actor.events) {
+ if (event.ts < from || event.ts > to) {
+ continue;
+ }
+ own.push({
+ ts: event.ts,
+ at: span === 0 ? 1 : Number(((event.ts - from) / span).toFixed(6)),
+ kind: event.kind,
+ });
+ }
+ ticks += own.length;
+ return {
+ id: actor.id,
+ name: actor.name,
+ color: actor.color,
+ letter: actor.letter,
+ you: actor.you,
+ idle: actor.idle,
+ ticks: own,
+ count: own.length,
+ };
+ });
+
+ return { mode, from, to, windowMs: span, tracks, ticks };
+}
+
+/** Die Zeit an dieser Stelle des Strahls. `fraction` liegt zwischen 0 und 1. */
+export function timeAtFraction(timeline: Timeline, fraction: number): number {
+ const clamped = fraction <= 0 ? 0 : fraction >= 1 ? 1 : fraction;
+ return Math.round(timeline.from + clamped * timeline.windowMs);
+}
diff --git a/graph-ui/src/agents/agent-view.test.ts b/graph-ui/src/agents/agent-view.test.ts
new file mode 100644
index 000000000..b2fc11331
--- /dev/null
+++ b/graph-ui/src/agents/agent-view.test.ts
@@ -0,0 +1,124 @@
+/*
+ * Die eine Rechnung, aus der die Ebene zeichnet und das Instrument schreibt.
+ *
+ * Der Punkt dieses Moduls ist, dass es nur EINE gibt: waeren es zwei, koennte
+ * ein Koerper an einem Knoten stehen und die Zeile daneben einen anderen
+ * nennen, und niemandem fiele es auf. Geprueft wird darum vor allem, dass die
+ * Zeile und der Ort aus demselben Ergebnis kommen.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import type { GraphNode } from '../galaxy/types';
+import { readAgentEvent } from './agent-event';
+import type { AgentEvent } from './agent-event';
+import { emptyAgentsState, withEvent, withYouEvent } from './agent-store';
+import { UNMAPPED_LIMIT, buildAgentsView } from './agent-view';
+
+const NOW = 1788040000000;
+
+const node = (over: Partial & { id: number }): GraphNode => ({
+ x: 0, y: 0, z: 0, label: 'Function', name: 'x', size: 1, color: '#ffffff', ...over,
+});
+
+const NODES: GraphNode[] = [
+ node({
+ id: 50, label: 'Module', name: 'src/services/userService.ts',
+ file_path: 'src/services/userService.ts', start_line: 1, end_line: 43,
+ }),
+ node({
+ id: 51, name: 'createUser', file_path: 'src/services/userService.ts',
+ start_line: 23, end_line: 36, qualified_name: 'p.createUser',
+ }),
+ node({ id: 5, label: 'Folder', name: 'src', file_path: 'src' }),
+ node({
+ id: 69, name: 'validateUser', file_path: 'src/util/validate.ts',
+ start_line: 19, end_line: 31,
+ }),
+];
+
+const event = (over: Partial): AgentEvent => readAgentEvent({
+ ts: NOW - 1000, agent: 'implementer', run: 'r1', seq: 1, phase: 'end',
+ tool: 'Edit', path: 'src/services/userService.ts', lines: [24, 30], detail: 'x', ...over,
+}) as AgentEvent;
+
+const build = (events: readonly AgentEvent[], over = {}) => buildAgentsView({
+ state: events.reduce((state, entry) => withEvent(state, entry), emptyAgentsState()),
+ nodes: NODES,
+ now: NOW,
+ filter: 'both',
+ trailWindowMs: 0,
+ ...over,
+});
+
+describe('die Sicht', () => {
+ it('gibt jedem Akteur Farbe, Buchstabe, Art und Ort aus einer Rechnung', () => {
+ const view = build([event({})]);
+ expect(view.actors).toHaveLength(1);
+ const actor = view.actors[0]!;
+ expect(actor.kind).toBe('write');
+ expect(actor.kindLetter).toBe('W');
+ expect(actor.letter).toBe('I');
+ expect(actor.placement.name).toBe('createUser');
+ expect(actor.node?.id).toBe(51);
+ expect(actor.color).toMatch(/^hsl\(/);
+ });
+
+ it('haelt die Ghost-Pings am selben Ergebnis wie den Koerper', () => {
+ const view = build([event({ tool: 'Grep', path: 'src', lines: undefined, detail: 'validate' })]);
+ const actor = view.actors[0]!;
+ expect(actor.kind).toBe('search');
+ expect(actor.placement.uncertain).toBe(true);
+ expect(actor.ghostNodes.map((ghost) => ghost.id)).toContain(69);
+ });
+
+ it('laesst ein unverortbares Ereignis stehen, statt es fallen zu lassen', () => {
+ const view = build([
+ event({ seq: 1, path: 'package.json', lines: undefined }),
+ event({ seq: 2, ts: NOW - 500 }),
+ ]);
+ expect(view.actors).toHaveLength(1);
+ expect(view.actors[0]?.placement.name).toBe('createUser');
+ expect(view.unmapped.map((entry) => entry.path)).toContain('package.json');
+ });
+
+ it('fragt die Art je Ereignis und nicht am Akteur', () => {
+ // Ein Testlauf von eben macht ein Lesen von vorhin nicht zu einem.
+ const view = build([
+ event({ seq: 1, tool: 'Read', path: 'package.json', lines: undefined }),
+ event({ seq: 2, ts: NOW - 500, tool: 'Bash', path: '', lines: undefined, detail: 'npx vitest run' }),
+ ]);
+ expect(view.actors[0]?.kind).toBe('test');
+ const reasons = view.unmapped.map((entry) => entry.why);
+ expect(reasons).toContain('the index has no node for this path');
+ expect(reasons).toContain('the command names no file this index knows');
+ });
+
+ it('zeigt hoechstens so viele unverortbare Zeilen, wie die Grenze erlaubt', () => {
+ const many = Array.from({ length: UNMAPPED_LIMIT + 4 }, (_, index) => event({
+ seq: index + 1, ts: NOW - 1000 - index, path: `nowhere-${index}.json`, lines: undefined,
+ }));
+ expect(build(many).unmapped).toHaveLength(UNMAPPED_LIMIT);
+ });
+
+ it('filtert wirklich, und die Zahl im Kopf zaehlt trotzdem alle', () => {
+ const state = withYouEvent(
+ withEvent(emptyAgentsState(), event({})),
+ event({ agent: 'you', run: 'this-window', tool: 'Open', ts: NOW - 200 }),
+ );
+ const base = { state, nodes: NODES, now: NOW, trailWindowMs: 0 } as const;
+ expect(buildAgentsView({ ...base, filter: 'both' }).actors).toHaveLength(2);
+ expect(buildAgentsView({ ...base, filter: 'you' }).actors.map((a) => a.id)).toEqual(['you']);
+ expect(buildAgentsView({ ...base, filter: 'agent' }).actors.map((a) => a.id))
+ .toEqual(['implementer']);
+ expect(buildAgentsView({ ...base, filter: 'you' }).all).toHaveLength(2);
+ });
+
+ it('traegt die Absicht des LETZTEN Ereignisses und keine von vorhin', () => {
+ const view = build([
+ event({ seq: 1, intent: 'an old plan' }),
+ event({ seq: 2, ts: NOW - 500 }),
+ ]);
+ expect(view.actors[0]?.intent).toBe('');
+ });
+});
diff --git a/graph-ui/src/agents/agent-view.ts b/graph-ui/src/agents/agent-view.ts
new file mode 100644
index 000000000..bb1c09991
--- /dev/null
+++ b/graph-ui/src/agents/agent-view.ts
@@ -0,0 +1,424 @@
+/**
+ * Was das Instrument zeigt und was die Ebene zeichnet, EINMAL gerechnet.
+ *
+ * Beide brauchen dieselbe Antwort: welcher Akteur steht wo, in welcher Art von
+ * Arbeit, seit wann, mit welcher Farbe. Zwei Rechnungen dafuer waeren zwei
+ * Wahrheiten ueber dasselbe Bild, und die Stelle, an der sie auseinanderlaufen,
+ * faellt niemandem auf: ein Koerper an einem Knoten und eine Zeile, die einen
+ * anderen nennt.
+ *
+ * Was hier NICHT passiert: deuten. Jedes Feld kommt aus einem Ereignis oder aus
+ * dem Index; die einzigen gerechneten Zahlen sind Zeitabstaende und Zaehlungen.
+ */
+
+import type { GraphNode } from '../galaxy/types';
+import { WORK_KIND_LETTERS, workKindOf } from './agent-event';
+import type { AgentEvent, WorkKind } from './agent-event';
+import { agentColor, agentLetters } from './agent-colors';
+import { buildPlacementIndex, placeEvent } from './agent-placement';
+import type { Placement, PlacementIndex } from './agent-placement';
+import {
+ STRIP_SECONDS,
+ activeActors,
+ activityStrip,
+ eventsInWindow,
+ eventsPerMinute,
+ recentPaths,
+ timeAtPlace,
+} from './agent-store';
+import type { ActorState, AgentsState } from './agent-store';
+import {
+ DRAWN_BODIES_CAP,
+ IDLE_MS,
+ PULSE_WINDOW_MS,
+ TRAIL_NODE_LIMIT,
+ currentBursts,
+ pulseOf,
+ writeBurstsOf,
+} from './agent-motion';
+import type { Pulse, WriteBurst } from './agent-motion';
+import { TICKER_LIMIT } from './agent-timeline';
+
+/** Wer gezeigt wird. */
+export type ActorFilter = 'you' | 'agent' | 'both';
+
+/** Die drei Lagen des Instruments. */
+export type HudSize = 'collapsed' | 'compact' | 'expanded';
+
+/**
+ * Wie weit die Wegzeile und die Spur zurueckreichen. `0` heisst: so weit wie
+ * behalten wird.
+ *
+ * Seit W11b vier Stufen statt drei: das Designbild nennt 60s, 5m, 15m und
+ * unbegrenzt, und die fuenfzehn Minuten fehlten. Sie sind die Stufe, in der man
+ * sieht, ob jemand seit einer Viertelstunde um dieselben drei Dateien kreist.
+ */
+export const TRAIL_WINDOWS: readonly number[] = [60000, 300000, 900000, 0];
+
+/** Hoechstens so viele Zeilen fuer das, was sich nicht verorten laesst. */
+export const UNMAPPED_LIMIT = 4;
+
+/** Hoechstens so viele Zeilen im kompakten Zustand. */
+export const COMPACT_ROW_LIMIT = 3;
+
+/** Ein Akteur, fertig fuer die Anzeige. */
+export interface ActorView {
+ id: string;
+ name: string;
+ you: boolean;
+ color: string;
+ /** Der Buchstabe am Koerper. Er unterscheidet die Akteure ohne die Farbe. */
+ letter: string;
+ kind: WorkKind;
+ /** Der Buchstabe der Art (R, W, S, T, O). Er steht im Instrument. */
+ kindLetter: string;
+ placement: Placement;
+ /** Der Knoten, den der Koerper umkreist. Fehlt, wenn nichts zu verorten war. */
+ node: GraphNode | undefined;
+ /** Der geprueften Bereich eines Testlaufs, wenn der Befehl ihn nennt. */
+ testedNode: GraphNode | undefined;
+ /** Die Knoten, deren Name das Suchmuster traegt. Nur beim Suchen. */
+ ghostNodes: readonly GraphNode[];
+ last: AgentEvent;
+ lastTs: number;
+ /** Wie lange dieser Akteur schon an derselben Stelle ist, in Millisekunden. */
+ hereMs: number;
+ count: number;
+ missed: number;
+ /** Ereignisse je Sekunde der letzten dreissig, aus den Ereignissen selbst. */
+ strip: readonly number[];
+ stripTotal: number;
+ /**
+ * Die Selbstauskunft des letzten Ereignisses. Leer, wenn keine da war.
+ *
+ * Leer heisst hier wirklich leer: es gibt keinen Ersatztext und keine
+ * abgeleitete Absicht.
+ */
+ intent: string;
+ /** Die zuletzt beruehrten Pfade, neuester zuerst. */
+ paths: readonly string[];
+ /** Wie lange das letzte Ereignis dieses Akteurs her ist, in Millisekunden. */
+ sinceMs: number;
+ /**
+ * Ob dieser Akteur seit ueber einer Minute nichts geliefert hat.
+ *
+ * Dann steht sein Koerper still, atmet nicht, wird blass und rutscht im
+ * Instrument nach unten. Er verschwindet nicht: sein Lauf laeuft weiter.
+ */
+ idle: boolean;
+ /** Wie viele Ereignisse dieser Akteur im Pulsfenster geliefert hat. */
+ recentEvents: number;
+ /** Der Puls, aus genau dieser Zahl gerechnet. Ohne Ereignisse: keiner. */
+ pulse: Pulse;
+ /**
+ * Die zuletzt besuchten Knoten, neuester zuerst.
+ *
+ * Der aktuelle Ort steht vorn, damit die Spur am Koerper anfaengt. Was
+ * ausserhalb des Fensters liegt oder sich nicht verorten liess, steht nicht
+ * darin: eine Spur durch einen Knoten, den kein Ereignis genannt hat, waere
+ * ein Weg, den niemand gegangen ist.
+ */
+ trail: readonly GraphNode[];
+ /** Die Schreib-Brueche, die gerade eine Welle tragen. */
+ waves: readonly WriteBurst[];
+ /** Ob dieser Koerper gezeichnet wird, oder ob der Deckel ihn zurueckhaelt. */
+ drawn: boolean;
+}
+
+/** Ein Ereignis, das sich nicht verorten liess. */
+export interface UnmappedEvent {
+ ts: number;
+ agent: string;
+ tool: string;
+ path: string;
+ detail: string;
+ why: string;
+}
+
+/** Eine Zeile des Ereignis-Tickers. Nur Gemessenes, in Worten. */
+export interface TickerEntry {
+ ts: number;
+ actor: string;
+ name: string;
+ color: string;
+ kind: WorkKind;
+ tool: string;
+ /** Der Name des getroffenen Knotens. Leer, wenn es keinen gab. */
+ place: string;
+ /** Der Zeilenbereich, wenn das Ereignis einen nannte. */
+ lines: readonly number[];
+ path: string;
+}
+
+/** Alles, was das Instrument und die Ebene brauchen. */
+export interface AgentsView {
+ /** Die gezeigten Akteure, nach dem Umschalter gefiltert. */
+ actors: readonly ActorView[];
+ /** Alle aktiven Akteure, ungefiltert. Der Kopf zaehlt an dieser Liste. */
+ all: readonly ActorView[];
+ unmapped: readonly UnmappedEvent[];
+ events: number;
+ missed: number;
+ perMinute: number;
+ unreadable: number;
+ /** Der Deckel, ueber dem keine Koerper mehr gezeichnet werden. */
+ cap: number;
+ /** Wie viele Akteure der Deckel gerade zurueckhaelt. */
+ capped: number;
+ /** Die letzten Ereignisse im Klartext, neuestes zuerst. */
+ ticker: readonly TickerEntry[];
+}
+
+export interface AgentsViewInput {
+ state: AgentsState;
+ nodes: readonly GraphNode[];
+ now: number;
+ filter: ActorFilter;
+ trailWindowMs: number;
+ /** Der Index, wenn der Aufrufer ihn schon hat. Sonst wird er gebaut. */
+ index?: PlacementIndex;
+ /** Hoechstens so viele Koerper werden gezeichnet. */
+ cap?: number;
+ /** Wie viele Zeilen der Ticker traegt. */
+ tickerLimit?: number;
+}
+
+function nodeById(nodes: readonly GraphNode[]): Map {
+ const out = new Map();
+ for (const node of nodes) {
+ out.set(node.id, node);
+ }
+ return out;
+}
+
+/**
+ * Die zuletzt besuchten Knoten dieses Akteurs, neuester zuerst.
+ *
+ * Gefragt wird jedes behaltene Ereignis im Fenster, von hinten nach vorn; was
+ * sich verorten laesst und noch nicht in der Liste steht, kommt dazu, bis der
+ * Deckel erreicht ist. Ein Knoten steht damit genau einmal darin, auch wenn ein
+ * Agent dreimal an dieselbe Stelle zurueckgekommen ist: die Spur ist ein Weg
+ * und keine Strichliste.
+ */
+function trailOf(
+ actor: ActorState,
+ now: number,
+ windowMs: number,
+ index: PlacementIndex,
+ byId: Map,
+): GraphNode[] {
+ const out: GraphNode[] = [];
+ const seen = new Set();
+ for (let i = actor.events.length - 1; i >= 0; i -= 1) {
+ const event = actor.events[i] as AgentEvent;
+ if (windowMs > 0 && now - event.ts > windowMs) {
+ break;
+ }
+ if (event.ts > now) {
+ continue;
+ }
+ const placement = placeEvent(event, workKindOf(event.tool, event.detail), index);
+ if (placement.nodeId === undefined || seen.has(placement.nodeId)) {
+ continue;
+ }
+ const node = byId.get(placement.nodeId);
+ if (node === undefined) {
+ continue;
+ }
+ seen.add(placement.nodeId);
+ out.push(node);
+ if (out.length >= TRAIL_NODE_LIMIT) {
+ break;
+ }
+ }
+ return out;
+}
+
+/** Die Schreib-Brueche dieses Akteurs, aus seinen verorteten Ereignissen. */
+function burstsOf(actor: ActorState, index: PlacementIndex): WriteBurst[] {
+ return writeBurstsOf(actor.events.map((event) => {
+ const kind = workKindOf(event.tool, event.detail);
+ const placement = placeEvent(event, kind, index);
+ return {
+ ts: event.ts,
+ nodeId: placement.nodeId ?? -1,
+ write: kind === 'write',
+ key: `${actor.id}:${event.run}:${event.seq}`,
+ };
+ }));
+}
+
+function viewOf(
+ actor: ActorState,
+ letter: string,
+ input: AgentsViewInput,
+ index: PlacementIndex,
+ byId: Map,
+): ActorView {
+ const placement = placeEvent(actor.last, actor.kind, index);
+ const strip = activityStrip(actor.events, input.now, STRIP_SECONDS);
+ const sinceMs = Math.max(0, input.now - actor.lastTs);
+ const idle = sinceMs > IDLE_MS;
+ /*
+ * Der Puls kommt aus der GEZAEHLTEN Zahl der Ereignisse im Fenster, und ein
+ * ruhiger Akteur bekommt gar keinen. Damit ist der Puls selbst eine
+ * Auskunft: er hoert auf, wenn die Arbeit aufhoert. Ein Herzschlag, der im
+ * Leerlauf weiterschlaegt, waere die Behauptung einer Arbeit, die nicht
+ * stattfindet.
+ */
+ const recentEvents = idle ? 0 : eventsInWindow(actor, input.now, PULSE_WINDOW_MS);
+ return {
+ id: actor.id,
+ name: actor.name,
+ you: actor.you,
+ color: agentColor(actor.id),
+ letter,
+ kind: actor.kind,
+ kindLetter: WORK_KIND_LETTERS[actor.kind],
+ placement,
+ node: placement.nodeId === undefined ? undefined : byId.get(placement.nodeId),
+ testedNode: placement.testedNodeId === undefined
+ ? undefined
+ : byId.get(placement.testedNodeId),
+ ghostNodes: placement.ghostIds
+ .map((id) => byId.get(id))
+ .filter((node): node is GraphNode => node !== undefined),
+ last: actor.last,
+ lastTs: actor.lastTs,
+ hereMs: timeAtPlace(actor, input.now),
+ count: actor.count,
+ missed: actor.missed,
+ strip,
+ stripTotal: strip.reduce((sum, value) => sum + value, 0),
+ intent: actor.last.intent ?? '',
+ paths: recentPaths(actor, input.now, input.trailWindowMs),
+ sinceMs,
+ idle,
+ recentEvents,
+ pulse: pulseOf(recentEvents),
+ trail: trailOf(actor, input.now, input.trailWindowMs, index, byId),
+ waves: idle ? [] : currentBursts(burstsOf(actor, index), actor.lastTs),
+ drawn: true,
+ };
+}
+
+/**
+ * Die Sicht bauen.
+ *
+ * Die Liste, die sich nicht verorten liess, entsteht aus denselben Ereignissen
+ * und nicht aus einer zweiten Quelle: gefragt wird jedes behaltene Ereignis
+ * jedes aktiven Akteurs, und was `none` ergibt, steht darin, neuestes zuerst.
+ */
+export function buildAgentsView(input: AgentsViewInput): AgentsView {
+ const index = input.index ?? buildPlacementIndex(input.nodes);
+ const byId = nodeById(input.nodes);
+ const active = activeActors(input.state, input.now);
+ const letters = agentLetters(active.map((actor) => ({ id: actor.id, name: actor.name })));
+
+ const all = active.map((actor) =>
+ viewOf(actor, letters.get(actor.id) ?? '?', input, index, byId));
+
+ const shown = all.filter((actor) => {
+ if (input.filter === 'both') {
+ return true;
+ }
+ return input.filter === 'you' ? actor.you : !actor.you;
+ });
+
+ /*
+ * Der Deckel greift an der GEZEICHNETEN Liste und nicht an der gezeigten.
+ *
+ * Die Reihenfolge ist die von `activeActors`: die juengste Bewegung zuerst.
+ * Wer ueber den Deckel faellt, ist damit der, dessen letztes Ereignis am
+ * laengsten her ist, und er steht weiter im Instrument, mit allen Zahlen.
+ * Was fehlt, ist sein Koerper auf dem Graphen, und genau das sagt das
+ * Instrument in einer eigenen Zeile.
+ */
+ const cap = input.cap ?? DRAWN_BODIES_CAP;
+ const actors = shown.map((actor, position) => ({ ...actor, drawn: position < cap }));
+ const capped = Math.max(0, shown.length - cap);
+ /*
+ * Auch die ungefilterte Liste traegt den Deckel, damit sie nicht behauptet,
+ * es waeren neun Koerper gezeichnet, wo acht stehen. Sie ist die Liste, an
+ * der der Kopf zaehlt und an der der Beweislauf liest.
+ */
+ const drawnIds = new Set(actors.filter((actor) => actor.drawn).map((actor) => actor.id));
+ const allWithCap = all.map((actor) => ({ ...actor, drawn: drawnIds.has(actor.id) }));
+
+ const unmapped: UnmappedEvent[] = [];
+ const seen = new Set();
+ for (const actor of active) {
+ for (let i = actor.events.length - 1; i >= 0; i -= 1) {
+ const event = actor.events[i] as AgentEvent;
+ // Die Art wird je Ereignis gefragt und nicht vom Akteur genommen:
+ // die Art des Akteurs ist die seines LETZTEN Ereignisses, und ein
+ // Testlauf von eben macht ein Lesen von vorhin nicht zu einem.
+ const placement = placeEvent(event, workKindOf(event.tool, event.detail), index);
+ if (placement.kind !== 'none') {
+ continue;
+ }
+ const key = `${event.run}:${event.seq}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ unmapped.push({
+ ts: event.ts,
+ agent: event.agent,
+ tool: event.tool,
+ path: event.path,
+ detail: event.detail,
+ why: placement.why,
+ });
+ }
+ }
+ unmapped.sort((a, b) => b.ts - a.ts);
+
+ /*
+ * Der Ticker: die letzten Ereignisse aller gezeigten Akteure, neuestes
+ * zuerst. Jede Zeile ist ein Ereignis und nennt, was daran gemessen ist:
+ * wer, welches Werkzeug, welcher Knoten, welche Zeilen. Was das Ereignis
+ * nicht sagt, steht nicht darin.
+ */
+ const ticker: TickerEntry[] = [];
+ const byActorId = new Map(actors.map((entry) => [entry.id, entry]));
+ for (const actor of active) {
+ const view = byActorId.get(actor.id);
+ if (view === undefined) {
+ continue;
+ }
+ for (let i = actor.events.length - 1; i >= 0; i -= 1) {
+ const event = actor.events[i] as AgentEvent;
+ if (event.ts > input.now) {
+ continue;
+ }
+ const kind = workKindOf(event.tool, event.detail);
+ const placement = placeEvent(event, kind, index);
+ ticker.push({
+ ts: event.ts,
+ actor: view.id,
+ name: view.name,
+ color: view.color,
+ kind,
+ tool: event.tool,
+ place: placement.kind === 'none' ? '' : placement.name,
+ lines: event.lines === undefined ? [] : [...event.lines],
+ path: event.path,
+ });
+ }
+ }
+ ticker.sort((a, b) => b.ts - a.ts);
+
+ return {
+ actors,
+ all: allWithCap,
+ unmapped: unmapped.slice(0, UNMAPPED_LIMIT),
+ events: input.state.events,
+ missed: input.state.missed,
+ perMinute: eventsPerMinute(input.state, input.now),
+ unreadable: input.state.unreadable,
+ cap,
+ capped,
+ ticker: ticker.slice(0, input.tickerLimit ?? TICKER_LIMIT),
+ };
+}
diff --git a/graph-ui/src/api/rpc.ts b/graph-ui/src/api/rpc.ts
deleted file mode 100644
index 2ceb70b47..000000000
--- a/graph-ui/src/api/rpc.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/* JSON-RPC client — speaks the same protocol as MCP clients via POST /rpc */
-
-let _nextId = 1;
-
-export class RpcError extends Error {
- constructor(
- public code: number,
- message: string,
- ) {
- super(message);
- this.name = "RpcError";
- }
-}
-
-export async function callTool(
- name: string,
- args: Record = {},
-): Promise {
- const res = await fetch("/rpc", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- jsonrpc: "2.0",
- id: _nextId++,
- method: "tools/call",
- params: { name, arguments: args },
- }),
- });
-
- if (!res.ok) {
- throw new RpcError(-1, `HTTP ${res.status}: ${res.statusText}`);
- }
-
- const json = await res.json();
-
- if (json.error) {
- throw new RpcError(json.error.code ?? -1, json.error.message ?? "unknown");
- }
-
- /* MCP tool results are wrapped: { result: { content: [{ text: "..." }] } } */
- const text = json?.result?.content?.[0]?.text;
- if (text === undefined) {
- return json.result as T;
- }
-
- return JSON.parse(text) as T;
-}
diff --git a/graph-ui/src/app/AtlasChrome.test.tsx b/graph-ui/src/app/AtlasChrome.test.tsx
new file mode 100644
index 000000000..969b7f14d
--- /dev/null
+++ b/graph-ui/src/app/AtlasChrome.test.tsx
@@ -0,0 +1,412 @@
+// @vitest-environment jsdom
+/*
+ * Das Chrome in jsdom: die Testmarken, die der Beweislauf im Browser sucht,
+ * der Wortlaut der Kopfzeile und die Stellen, an denen die Oberflaeche zugibt,
+ * dass sie etwas noch nicht kann.
+ *
+ * Monaco wird hier nicht geladen: der Reader kommt als Kind herein. Das ist der
+ * ganze Grund fuer den Schnitt zwischen AtlasChrome und App.
+ */
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import AtlasChrome, { splitMenuLabel } from './AtlasChrome';
+import type { AtlasChromeProps } from './AtlasChrome';
+import type { TreeRow } from './tree-model';
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => {
+ root.unmount();
+ });
+ container.remove();
+});
+
+const fileRow: TreeRow = {
+ name: 'userService.ts',
+ path: 'src/services/userService.ts',
+ kind: 'file',
+ symbols: 6,
+ files: 1,
+ depth: 1,
+ expanded: false,
+ loaded: true,
+};
+
+function props(overrides: Partial = {}): AtlasChromeProps {
+ return {
+ version: 'v0.0.0-dirty',
+ chips: [{ label: 'project', value: 'atlas-sample' }],
+ tabs: [],
+ onSelectTab: vi.fn(),
+ onCloseTab: vi.fn(),
+ tree: {
+ projectName: 'atlas-sample',
+ rows: [fileRow],
+ cursor: 0,
+ activePath: '',
+ note: '18 files, 71 symbols from /api/tree',
+ onCursorChange: vi.fn(),
+ onOpen: vi.fn(),
+ onToggle: vi.fn(),
+ onKeyDown: vi.fn(),
+ },
+ breadcrumb: [],
+ children: ,
+ truncationNote: '',
+ commandValue: '',
+ onCommandChange: vi.fn(),
+ commandHint: 'type 2 letters to search by meaning',
+ status: [{ label: 'server', value: 'ready', state: 'ok' }],
+ ...overrides,
+ };
+}
+
+async function render(next: AtlasChromeProps): Promise {
+ await act(async () => {
+ root.render();
+ });
+}
+
+const testId = (id: string): HTMLElement | null => container.querySelector(`[data-testid="${id}"]`);
+
+describe('AtlasChrome', () => {
+
+ it('leaves general configuration out of the top bar', async () => {
+ const onOpenConfig = vi.fn();
+ await render(props({ onOpenConfig, configOpen: true, onOpenBrowserAi: vi.fn(), projectSwitcher: }));
+ expect(testId('atlas-header')?.querySelector('[aria-label="Open configuration"]')).toBeNull();
+ expect(testId('atlas-header')?.lastElementChild?.getAttribute('aria-label')).toBe('Local agent');
+ expect(onOpenConfig).not.toHaveBeenCalled();
+ });
+
+ it('shows a robot with a status lamp and keeps the model name in the tooltip and the label', async () => {
+ const settings = vi.fn(), toggle = vi.fn();
+ await render(props({ agentState: 'active', agentModelName: 'Qwen 3.5 0.8B', onOpenBrowserAi: settings, onToggleBrowserAi: toggle }));
+ const button = container.querySelector('.atlas-browser-ai-action')!;
+ expect(button.textContent).toBe('');
+ expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
+ expect(button.querySelector(':scope > span[aria-hidden="true"]')).not.toBeNull();
+ expect(button.dataset.state).toBe('active');
+ expect(button.getAttribute('aria-label')).toBe('Local agent settings: Agent active, Qwen 3.5 0.8B');
+ expect(button.title).toBe('Agent active · Qwen 3.5 0.8B. Configure the browser-local agent.');
+ await act(async () => button.click());
+ expect(settings).toHaveBeenCalledOnce();
+ expect(toggle).not.toHaveBeenCalled();
+ expect(container.querySelector('[role="menu"]')).toBeNull();
+ });
+
+ it('omits the model from the label when no model is selected', async () => {
+ await render(props({ agentState: 'off', onOpenBrowserAi: vi.fn() }));
+ expect(container.querySelector('.atlas-browser-ai-action')?.getAttribute('aria-label')).toBe('Local agent settings: Agent off');
+ });
+
+ it.each([['off', 'Agent off'], ['active', 'Agent active'], ['busy', 'Agent working'], ['loading', 'Agent loading'], ['error', 'Agent error']] as const)('shows truthful local agent state %s and opens settings beside the project', async (agentState, label) => {
+ const settings = vi.fn();
+ await render(props({ agentState, onOpenBrowserAi: settings, projectSwitcher: }));
+ const button = container.querySelector('.atlas-browser-ai-action')!;
+ expect(button.dataset.state).toBe(agentState);
+ expect(button.getAttribute('aria-label')).toBe(`Local agent settings: ${label}`);
+ expect(button.title).toContain(label);
+ expect(button.parentElement?.previousElementSibling?.textContent).toBe('Project');
+ await act(async () => button.click());
+ expect(settings).toHaveBeenCalledOnce();
+ });
+
+ it.each(['explore', 'architecture', 'galaxy', 'system', 'adr', 'coverage', 'agents'] as const)('groups independent configuration and chat controls in the top bar for %s', async workspace => {
+ const settings = vi.fn(), toggle = vi.fn();
+ const next = (chatOpen: boolean) => props({ workspace, chatOpen, onOpenBrowserAi: settings, onToggleBrowserAi: toggle, chatDock: });
+ await render(next(false));
+ const group = testId('atlas-header')?.querySelector('[role="group"][aria-label="Local agent"]');
+ expect(group?.querySelectorAll('button')).toHaveLength(2);
+ const draft = container.querySelector('[aria-label="Chat draft"]')!;
+ draft.value = 'retained draft';
+ const settingsButton = group?.querySelector('.atlas-browser-ai-action')!;
+ const openButton = group?.querySelector('[aria-label="Open chat"]')!;
+ expect(openButton.getAttribute('aria-expanded')).toBe('false');
+ await act(async () => settingsButton.click());
+ expect(settings).toHaveBeenCalledOnce();
+ expect(toggle).not.toHaveBeenCalled();
+ await act(async () => openButton.click());
+ expect(toggle).toHaveBeenCalledOnce();
+ expect(settings).toHaveBeenCalledOnce();
+ await render(next(true));
+ const hideButton = testId('atlas-header')?.querySelector('[aria-label="Hide chat"]')!;
+ expect(hideButton.getAttribute('aria-expanded')).toBe('true');
+ await act(async () => hideButton.click());
+ expect(toggle).toHaveBeenCalledTimes(2);
+ expect(settings).toHaveBeenCalledOnce();
+ await render(next(false));
+ expect(container.querySelector('[aria-label="Chat draft"]')).toBe(draft);
+ expect(draft.value).toBe('retained draft');
+ expect(container.querySelector('.atlas-agent-reopen')).toBeNull();
+ expect(container.querySelector('[role="menu"]')).toBeNull();
+ });
+
+ it('traegt jede Testmarke, an der der Beweislauf das Chrome erkennt', async () => {
+ await render(props());
+ for (const id of ['atlas-header', 'atlas-tabs',
+ 'atlas-statusbar', 'atlas-tree', 'atlas-breadcrumb']) {
+ expect(testId(id), `${id} fehlt`).not.toBeNull();
+ }
+ });
+
+ it('zeigt Marke und Versions-Chip in der Kopfzeile', async () => {
+ await render(props());
+ expect(container.querySelector('.atlas-brand')?.textContent).toBe('CBM LENS Beta');
+ expect(container.querySelector('.atlas-brand-beta')?.textContent).toBe('Beta');
+ expect(testId('atlas-version')?.textContent).toBe('v0.0.0');
+ });
+
+ it('keeps project switching at the end of the header without a duplicate project chip', async () => {
+ await render(props({
+ chips: [{ label: 'project', value: 'atlas-sample' }, { label: 'sym', value: '76' }],
+ ...{ projectSwitcher: },
+ }));
+ const header = testId('atlas-header');
+ expect(header?.lastElementChild?.textContent).toBe('Switch project: atlas-sample');
+ expect(header?.querySelector('[data-chip="project"]')).toBeNull();
+ expect(header?.querySelector('[data-chip="sym"]')?.textContent).toContain('76');
+ });
+
+ it('omits build diagnostics from the header', async () => {
+ await render(props({ version: 'v1.0.0', buildSuffix: 'dirty' }));
+ expect(testId('atlas-version')?.textContent).toBe('v1.0.0');
+ expect(testId('atlas-version-suffix')).toBeNull();
+ });
+
+ it('zeigt gar kein Zusatz-Element, wenn der Baum sauber war', async () => {
+ await render(props({ version: 'v1.0.0', buildSuffix: '' }));
+ expect(testId('atlas-version')?.textContent).toBe('v1.0.0');
+ expect(testId('atlas-version-suffix')).toBeNull();
+ });
+
+ it.each(['on', 'off'] as const)('keeps removed tool menus absent even when legacy actions are supplied (%s)', async state => {
+ const onSelect = vi.fn(); const extra = vi.fn(); const help = vi.fn();
+ await render(props({ menus: {
+ a: { title: 'Legacy tools', state, onSelect, extras: [
+ { key: 'why', label: '[w]hy am I here', title: 'Where to start', onSelect: extra },
+ { key: 'bug', label: '[b]ug hunt', title: 'Bug hunt', onSelect: extra },
+ { key: 'impact', label: '[c]hange scope', title: 'Impact', onSelect: extra },
+ { key: 'llm', label: '[l]lm off', title: 'Model', onSelect: extra },
+ ] },
+ '?': { title: 'Help', state, onSelect: help },
+ } }));
+ expect(testId('atlas-menu')).toBeNull();
+ expect(testId('atlas-menu-legend')).toBeNull();
+ expect(container.querySelector('[data-menu]')).toBeNull();
+ expect(container.querySelector('.atlas-tools-menu')).toBeNull();
+ expect(testId('fake-reader')).not.toBeNull();
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(extra).not.toHaveBeenCalled();
+ expect(help).not.toHaveBeenCalled();
+ });
+
+ it('laesst ein Etikett ohne Klammer ganz stehen, statt eine zu erfinden', () => {
+ expect(splitMenuLabel('[w]hy am I here')).toEqual({ key: 'w', rest: 'hy am I here' });
+ expect(splitMenuLabel('[l]lm off')).toEqual({ key: 'l', rest: 'lm off' });
+ expect(splitMenuLabel('plain')).toEqual({ key: '', rest: 'plain' });
+ });
+
+ it('does not render a global command bar, search dialog or legacy search results', async () => {
+ const onCommandChange = vi.fn();
+ await render(props({ onCommandChange, commandValue: 'old query', commandOverlay: }));
+ expect(testId('atlas-command')).toBeNull();
+ expect(testId('atlas-command-input')).toBeNull();
+ expect(testId('fake-overlay')).toBeNull();
+ expect(container.querySelector('dialog')).toBeNull();
+ expect(container.querySelector('.atlas-command-hint')).toBeNull();
+ expect(onCommandChange).not.toHaveBeenCalled();
+ });
+
+ it('preserves the reader and galaxy when obsolete command props are supplied', async () => {
+ await render(props({ commandOverlay: ,
+ twin: , galaxy: }));
+ expect(testId('fake-reader')).not.toBeNull();
+ expect(testId('fake-overlay')).toBeNull();
+ const side = container.querySelector('.atlas-side');
+ expect(side?.querySelector('[data-testid="fake-twin"]')).not.toBeNull();
+ expect(side?.querySelector('[data-testid="fake-galaxy"]')).not.toBeNull();
+ });
+
+ it('baut keine dritte Spalte, wenn weder Twin noch Galaxie hereingereicht wurden', async () => {
+ await render(props());
+ expect(container.querySelector('.atlas-side')).toBeNull();
+ });
+
+ it('sagt ohne offene Datei, dass keine offen ist, statt eine leere Zeile zu zeigen', async () => {
+ await render(props());
+ expect(testId('atlas-tabs')?.textContent).toContain('no file open');
+ expect(testId('atlas-breadcrumb')?.textContent).toContain('no file open');
+ });
+
+ it('schreibt die Breadcrumb mit dem Trenner des Vorbilds', async () => {
+ await render(props({ breadcrumb: ['src', 'services', 'userService.ts'] }));
+ const text = testId('atlas-breadcrumb')?.textContent ?? '';
+ expect(text).toBe('src › services › userService.ts');
+ expect(container.querySelector('.atlas-breadcrumb-leaf')?.textContent).toBe('userService.ts');
+ });
+
+ it('zeigt den aktiven Tab mit Punkt und meldet Auswahl und Schliessen', async () => {
+ const onSelectTab = vi.fn();
+ const onCloseTab = vi.fn();
+ await render(props({
+ tabs: [
+ { path: 'src/types.ts', name: 'types.ts', active: false },
+ { path: 'src/services/userService.ts', name: 'userService.ts', active: true },
+ ],
+ onSelectTab,
+ onCloseTab,
+ }));
+ const tabs = testId('atlas-tabs')?.querySelectorAll('.atlas-tab') ?? [];
+ expect(tabs).toHaveLength(2);
+ expect(tabs[1]?.getAttribute('data-active')).toBe('true');
+ expect(tabs[1]?.querySelector('.atlas-tab-dot')).not.toBeNull();
+
+ await act(async () => {
+ (tabs[0]?.querySelector('.atlas-tab-label') as HTMLButtonElement).click();
+ });
+ expect(onSelectTab).toHaveBeenCalledWith('src/types.ts');
+
+ await act(async () => {
+ (tabs[1]?.querySelector('.atlas-tab-close') as HTMLButtonElement).click();
+ });
+ expect(onCloseTab).toHaveBeenCalledWith('src/services/userService.ts');
+ });
+
+ /*
+ * Die Tab-Leiste (W5c, Nutzerfeedback 2026-08-29).
+ *
+ * In jsdom faellt kein Layout an: `scrollWidth` und `clientWidth` sind
+ * dort beide null, also kann hier nicht gemessen werden, dass die Leiste
+ * ueberlaeuft. Das misst der Beweislauf im Browser. Geprueft wird hier,
+ * was ein Browserlauf nur umstaendlich fragen koennte: dass die Leiste ein
+ * eigener Bildlaufbereich mit Ueberlauf-Anzeigen ist, dass ein Rad-Ereignis
+ * darin waagerecht bewegt und dass ein Tab seinen Pfad als Tooltip traegt.
+ */
+ it('gibt der Tab-Leiste einen eigenen Bildlaufbereich mit zwei Anzeigen', async () => {
+ await render(props({
+ tabs: [
+ { path: 'src/types.ts', name: 'types.ts', active: true },
+ { path: 'src/config.ts', name: 'config.ts', active: false },
+ ],
+ }));
+ expect(testId('atlas-tabs-bar')).not.toBeNull();
+ expect(testId('atlas-tabs')?.parentElement).toBe(testId('atlas-tabs-bar'));
+ const marks = [...(testId('atlas-tabs-bar')?.querySelectorAll('[data-testid="atlas-tabs-overflow"]') ?? [])];
+ expect(marks.map((mark) => mark.getAttribute('data-side'))).toEqual(['left', 'right']);
+ // Ohne Ueberlauf leuchtet keine der beiden.
+ expect(marks.every((mark) => mark.getAttribute('data-on') === 'false')).toBe(true);
+ });
+
+ it('nimmt eine Radumdrehung ueber der Leiste an sich, statt sie durchzulassen', async () => {
+ await render(props({
+ tabs: [{ path: 'src/types.ts', name: 'types.ts', active: true }],
+ }));
+ const bar = testId('atlas-tabs') as HTMLElement;
+ // jsdom rechnet kein Layout: ohne diese zwei Zahlen weiss die Leiste
+ // nicht, dass es etwas zu scrollen gibt, und laesst das Rad in Ruhe.
+ Object.defineProperty(bar, 'scrollWidth', { value: 900, configurable: true });
+ Object.defineProperty(bar, 'clientWidth', { value: 300, configurable: true });
+ const wheel = new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true });
+ await act(async () => {
+ bar.dispatchEvent(wheel);
+ });
+ // Abbestellt heisst: die Leiste bewegt sich genau einmal, naemlich hier,
+ // und nicht zusaetzlich noch einmal durch die Vorgabe der Engine.
+ expect(wheel.defaultPrevented).toBe(true);
+ });
+
+ it('laesst das Rad in Ruhe, solange nichts ueberlaeuft', async () => {
+ await render(props({
+ tabs: [{ path: 'src/types.ts', name: 'types.ts', active: true }],
+ }));
+ const bar = testId('atlas-tabs') as HTMLElement;
+ const wheel = new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true });
+ await act(async () => {
+ bar.dispatchEvent(wheel);
+ });
+ expect(wheel.defaultPrevented).toBe(false);
+ });
+
+ it('haengt den ganzen Pfad an den Tab, weil der Name allein mehrdeutig ist', async () => {
+ await render(props({
+ tabs: [{ path: 'src/services/userService.ts', name: 'userService.ts', active: true }],
+ }));
+ expect(testId('atlas-tab')?.getAttribute('data-hint')).toBe('src/services/userService.ts');
+ });
+
+ it('zeigt die Kappungszeile nur, wenn es etwas zu melden gibt', async () => {
+ await render(props());
+ expect(testId('atlas-truncation')).toBeNull();
+ await render(props({ truncationNote: 'lines 501-718 not loaded: server snippet cap' }));
+ expect(testId('atlas-truncation')?.textContent).toContain('501-718');
+ expect(testId('atlas-truncation')?.textContent).toContain('incomplete');
+ });
+
+ it('stellt die Coverage-Notiz ueber den Editor und nicht darunter', async () => {
+ await render(props());
+ expect(testId('atlas-coverage-note')).toBeNull();
+ await render(props({
+ coverageNote: {
+ state: 'partially parsed',
+ text: 'src/broken.ts is only partially parsed: constructs inside lines 12-18 may be missing.',
+ },
+ }));
+ const note = testId('atlas-coverage-note');
+ expect(note?.textContent).toContain('12-18');
+ expect(note?.getAttribute('data-coverage')).toBe('partially parsed');
+ // Ueber dem Editor: die Notiz qualifiziert den Text, den man gleich
+ // liest, und muss vor dem ersten Zeichen dastehen.
+ const reader = testId('atlas-reader');
+ const position = note === null || reader === null
+ ? 0
+ : note.compareDocumentPosition(reader);
+ expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('faerbt eine Abwesenheit anders als eine Zahl', async () => {
+ await render(props({
+ chips: [
+ { label: 'project', value: 'no project', state: 'absent' },
+ { label: 'sym', value: '76' },
+ ],
+ }));
+ const absent = container.querySelector('[data-chip="project"]');
+ expect(absent?.getAttribute('data-state')).toBe('absent');
+ expect(container.querySelector('[data-chip="sym"]')?.getAttribute('data-state')).toBe('plain');
+ });
+
+ it('reicht die Reader-Flaeche durch, ohne sie zu kennen', async () => {
+ await render(props());
+ expect(testId('atlas-reader')?.querySelector('[data-testid="fake-reader"]')).not.toBeNull();
+ });
+
+ it('only reserves an explanation splitter while a tool is open, preserving the reader', async () => {
+ const reader = ;
+ const next = props({ children: reader, splitExplain: });
+ await render(next);
+ const mountedReader = testId('preserved-reader');
+ expect(testId('explanation-splitter')).toBeNull();
+
+ await render({ ...next, explain: Flow });
+ expect(testId('explanation-splitter')).not.toBeNull();
+ expect(testId('opened-tool')).not.toBeNull();
+ expect(testId('preserved-reader')).toBe(mountedReader);
+
+ await render(next);
+ expect(testId('explanation-splitter')).toBeNull();
+ expect(testId('opened-tool')).toBeNull();
+ expect(testId('preserved-reader')).toBe(mountedReader);
+ expect((mountedReader as HTMLInputElement).value).toBe('selected source');
+ });
+});
diff --git a/graph-ui/src/app/AtlasChrome.tsx b/graph-ui/src/app/AtlasChrome.tsx
new file mode 100644
index 000000000..b4adaabc6
--- /dev/null
+++ b/graph-ui/src/app/AtlasChrome.tsx
@@ -0,0 +1,821 @@
+/**
+ * Workspace chrome: header, menu, tabs, explorer, breadcrumbs, reader,
+ * command line, and status bar.
+ *
+ * Rein darstellend, und das ist der Punkt: der Reader kommt als `children`
+ * herein, nicht als Import. So laesst sich das ganze Chrome in jsdom pruefen,
+ * ohne Monaco zu laden, und der Beweislauf im Browser prueft danach genau das,
+ * was jsdom nicht kann, naemlich den Editor selbst.
+ *
+ * Die Menuezeile zeichnet nur, was etwas tut.
+ *
+ * Bis W3 war ein Menuepunkt ohne Verdrahtung ein Knopf mit einem `title`, der
+ * sagte, dass er nichts tut; seit W3 galt das Punkt fuer Punkt statt pauschal.
+ * Seit W7a (Nutzerauftrag 2026-08-29) gilt es gar nicht mehr: ein Punkt ohne
+ * Verdrahtung wird NICHT gezeichnet. Ein Tooltip macht aus einer Attrappe keine
+ * Auskunft, sondern eine Attrappe mit Fussnote, und ein Menue, das sich
+ * anklicken laesst und schweigt, ist die Sorte stille Luege, gegen die dieses
+ * Produkt gebaut ist.
+ *
+ * Das ist hier die Notbremse und nicht der Normalfall: welche Punkte es gibt,
+ * entscheidet `messages.menu.items`, und dass jeder davon einen Buchstaben in
+ * WIRED_MENU_SHORTCUTS hat, prueft src/app/shortcuts.test.ts. Faellt eine
+ * Verdrahtung trotzdem einmal aus, fehlt der Punkt, statt stumm dazustehen.
+ *
+ * Die Kommandozeile bekommt ihren Hinweis und ihren `title` von aussen, weil
+ * das, was sie tut, nicht mehr hier entschieden wird.
+ */
+import type { CSSProperties, JSX, KeyboardEvent, ReactNode, RefObject } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import AtlasTree from './AtlasTree';
+import type { AtlasTreeProps } from './AtlasTree';
+import { messages } from '../i18n/messages';
+import type { CommandExample } from '../search/command-examples';
+import Hint from '../ui/tooltip/Hint';
+import { LAYOUT_DEFAULT } from '../layout/layout-model';
+import Splitter from '../layout/Splitter';
+import { availableWorkspaces, workspaceStrings } from './workspace-strings';
+import type { Workspace, Guidance } from './workspace-strings';
+import './agent-status.css';
+
+/** Ein Menuepunkt: sein Buchstaben-Kuerzel und der Rest des Wortes. */
+export interface MenuItem {
+ key: string;
+ rest: string;
+}
+
+/**
+ * Was ein verdrahteter Menuepunkt tut.
+ *
+ * `state` faerbt ihn: `on` heisst, die Flaeche dahinter ist zu sehen, `off`
+ * heisst, es gibt sie und sie ist gerade zu. Einen dritten Wert fuer "gibt es
+ * nicht" braucht es seit W7a nicht mehr: was es nicht gibt, steht nicht in der
+ * Zeile.
+ */
+export interface MenuWiring {
+ title: string;
+ state: 'on' | 'off';
+ onSelect: () => void;
+ /**
+ * Further entries of the same menu, drawn beside it.
+ *
+ * A dropdown would be the ordinary shape and it is not the shape here, for
+ * one reason: the letter itself is already a verb. `[a]tlas` shows and hides
+ * the galaxy on click and on the `a` key, and the W3 proof run turns on that
+ * meaning. Turning the item into a trigger that only opens a list would
+ * change what a click on it does, which is a change to a proven behaviour
+ * made in passing for a second entry's sake. So the extra entries sit next to
+ * it as their own small buttons, each with its own key in `data-menu`, and
+ * the primary letter keeps meaning what it has always meant.
+ */
+ extras?: readonly MenuExtra[];
+}
+
+/** One further entry of a menu: its own label, its own tooltip, its own act. */
+export interface MenuExtra {
+ /** Suffix of `data-menu`, so the entry is addressable as `a-why`. */
+ key: string;
+ label: string;
+ title: string;
+ onSelect: () => void;
+}
+
+/**
+ * Der Klammer-Buchstabe eines Etiketts und der Rest davon.
+ *
+ * Nutzerbefund vom 2026-08-29 (Screenshot der Menuezeile): `[a]tlas` und
+ * `[?]help` trugen einen Rahmen, die vier Eintraege dazwischen nur einen
+ * gepunkteten Unterstrich, und dadurch sahen sie aus wie Text neben
+ * Bedienelementen. Sie sind aber dasselbe: sechs Knoepfe, jeder mit einem
+ * Buchstaben, jeder mit einer Handlung dahinter.
+ *
+ * Die vier Eintraege fuehren ihren Buchstaben bisher IM Etikett (`[w]hy am I
+ * here`), weil das Etikett an mehreren Stellen gelesen wird und dort seinen
+ * Buchstaben zeigen soll. Damit sie trotzdem dieselbe Gestalt bekommen wie die
+ * beiden anderen, wird der Buchstabe hier wieder herausgeloest und in dasselbe
+ * `` gesetzt. Das ist eine Ablesung des Etiketts
+ * und keine zweite Quelle: steht keine Klammer darin, bleibt das Etikett ganz
+ * stehen, statt dass eine erfundene Klammer erscheint.
+ */
+export function splitMenuLabel(label: string): MenuItem {
+ const match = /^\s*\[([^\]])\](.*)$/.exec(label);
+ return match === null ? { key: '', rest: label } : { key: match[1], rest: match[2] };
+}
+
+/**
+ * Die Menuezeile aus dem Vorbild. Jeder Punkt traegt seinen Buchstaben, und der
+ * Buchstabe steht in Klammern, weil ein Terminal keine Unterstreichung hat.
+ */
+export const MENU_ITEMS: readonly MenuItem[] = messages.menu.items;
+
+/**
+ * Der Platzhalter der Kommandozeile, wenn kein Index geladen ist.
+ *
+ * Bis W8b war er der einzige, und er nannte eine GATTUNG ("type a command or
+ * ask the atlas"). Der Nutzer am 2026-08-29: "anstelle von 'type a command or
+ * ask the atlas' explizite Beispiele, wie man den Chat nutzt, sonst weiss
+ * niemand, wie man es nutzt." Seit W8b nennt die Zeile ein echtes Beispiel mit
+ * einem Symbol dieses Projekts (src/search/command-examples.ts); dieser Satz
+ * bleibt fuer den Fall, dass noch nichts geladen ist, und das ist der ehrliche
+ * Rueckfall: ein Beispiel mit einem erfundenen Namen wuerde behaupten, es gebe
+ * dieses Symbol.
+ */
+export const COMMAND_PLACEHOLDER = messages.command.placeholder;
+
+/** Eine Angabe in der Kopfzeile oder der Statusleiste. */
+export interface Chip {
+ label: string;
+ value: string;
+ /** `absent` faerbt die Angabe als Abwesenheit statt als Zahl. */
+ state?: 'ok' | 'absent' | 'plain';
+}
+
+/** Ein offener Tab. */
+export interface TabDescriptor {
+ path: string;
+ name: string;
+ active: boolean;
+}
+
+export interface AtlasChromeProps {
+ projectSwitcher?: ReactNode;
+ onOpenConfig?: () => void;
+ configOpen?: boolean;
+ onOpenBrowserAi?: () => void;
+ agentState?: 'off' | 'loading' | 'active' | 'busy' | 'error';
+ agentModelName?: string;
+ onToggleBrowserAi?: () => void;
+ chatOpen?: boolean;
+ chatDock?: ReactNode;
+ /** Persistent code context, shared by the Explore sidebar and chat column. */
+ selectionInspector?: ReactNode;
+ readerActions?: ReactNode;
+ readerDetails?: ReactNode;
+ readerSummary?: ReactNode;
+ onOpenSystem?: () => void;
+ daemonState?: 'connected' | 'disconnected' | 'checking';
+ globalOverlay?: ReactNode;
+ workspace?: Workspace;
+ experimentalAgents?: boolean;
+ onWorkspaceChange?: (workspace: Workspace) => void;
+ workspacePanel?: ReactNode;
+ workspaceStatus?: ReactNode;
+ guidance?: Guidance;
+ /** Versions-Chip, zur Buildzeit injiziert. Nur die Fassung, ohne Zusatz. */
+ version: string;
+ /** Build diagnostics are accepted for compatibility but not shown in the header. */
+ buildSuffix?: string;
+ chips: Chip[];
+ tabs: TabDescriptor[];
+ onSelectTab: (path: string) => void;
+ onCloseTab: (path: string) => void;
+ tree: AtlasTreeProps;
+ /** Pfadsegmente der offenen Datei. Leer heisst: keine Datei offen. */
+ breadcrumb: string[];
+ /** Die Editor-Flaeche. Kommt von aussen, damit dieses Modul Monaco nicht kennt. */
+ children: ReactNode;
+ /** Ehrliche Zeile unter dem Editor, wenn nicht die ganze Datei angekommen ist. */
+ truncationNote: string;
+ /**
+ * Die Coverage-Notiz UEBER dem Editor, wenn der Index diese Datei nur zum
+ * Teil kennt.
+ *
+ * Ueber und nicht unter dem Text, und das ist der ganze Unterschied: die
+ * Kappungszeile sagt "hier hoert das Geladene auf" und gehoert ans Ende,
+ * die Coverage-Notiz sagt "was du gleich liest, ist im Graphen
+ * unvollstaendig" und muss vor dem ersten gelesenen Zeichen dastehen.
+ */
+ coverageNote?: { state: string; text: string } | undefined;
+ /**
+ * Der Erklaeren-Bereich, unter dem Reader und ueber der Kommandozeile.
+ *
+ * Seit W8 EIN Platz statt dreier. Bis dahin standen hier zwei Aufnahmen
+ * nebeneinander, `tour` und `chat`, und der Flow-Erklaerer lag als drittes
+ * ueber dem Editor. Der Nutzerbefund vom 2026-08-29 zeigte alle drei
+ * gleichzeitig, uebereinander und angeschnitten. Was einander ersetzt, teilt
+ * sich jetzt einen Platz; welcher Reiter gilt, entscheidet App.tsx, und was
+ * dieser Bereich ist, steht in src/layout/ExplainZone.tsx.
+ *
+ * Wie der Reader kommt er als Knoten von aussen herein: dieses Modul soll
+ * die fuenf Flaechen so wenig kennen wie den Editor.
+ */
+ explain?: ReactNode;
+ /**
+ * Die rechte Spalte. Kommt von aussen, wie der Reader, und aus demselben
+ * Grund: dieses Modul soll das Panel so wenig kennen wie den Editor.
+ *
+ * Fehlt sie, gibt es keine dritte Spalte. Eine leere Spalte zu reservieren
+ * waere die Behauptung, es gaebe dort etwas, das gerade nichts sagt.
+ */
+ twin?: ReactNode;
+ /**
+ * Die Galaxie, unter dem Twin in derselben Spalte.
+ *
+ * Warum unter dem Twin und nicht in der Mitte: die Mitte ist der Reader,
+ * und ein Panel, das ihn verdraengt, macht aus zwei gleichzeitigen
+ * Ansichten ein Umschalten. Die rechte Spalte ist ausserdem die einzige
+ * Stelle, an der das Chrome dafuer nichts umbauen muss.
+ */
+ galaxy?: ReactNode;
+ /**
+ * Die Karte des lokalen Modells, ganz oben in derselben rechten Spalte.
+ *
+ * Oben und nicht unten, und das ist der einzige Grund, aus dem sie ueberhaupt
+ * eine feste Stelle hat: sie ist zwei Zeilen hoch, solange nichts laeuft, und
+ * sie beantwortet die Frage "redet dieses Fenster gerade mit einem Modell".
+ * Diese Frage will man beantwortet sehen, bevor man liest, nicht nachdem man
+ * an zwei Panels vorbeigescrollt ist.
+ */
+ llm?: ReactNode;
+ /**
+ * Die drei Masse, die das Raster braucht.
+ *
+ * Nur drei und nicht vier: die Hoehe des Erklaeren-Bereichs traegt der
+ * Bereich selbst, weil nur er weiss, ob er gerade eingeklappt ist. Die
+ * Angabe kommt als CSS-Variable ins Raster und nicht als `grid-template`
+ * aus JavaScript, damit die Regeln in terminal.css stehen bleiben, wo alle
+ * anderen auch stehen: eine Spaltenbreite, die in zwei Dateien entsteht,
+ * ist eine Spaltenbreite, die in zwei Dateien auseinanderlaeuft.
+ */
+ zones?: { leftWidth: number; rightWidth: number; twinHeight: number };
+ /** Der Griff zwischen Explorer und Mitte. */
+ splitLeft?: ReactNode;
+ /** Der Griff zwischen Reader und Erklaeren-Bereich. */
+ splitExplain?: ReactNode;
+ /** Der Griff zwischen Mitte und rechter Spalte. */
+ splitRight?: ReactNode;
+ /** Der Griff zwischen Twin und Graph. */
+ splitTwin?: ReactNode;
+ /** Verdrahtete Menuepunkte, je Buchstabe. Was fehlt, sagt weiter nichts zu tun. */
+ menus?: Partial>;
+ commandValue: string;
+ onCommandChange: (value: string) => void;
+ /**
+ * Tasten der Kommandozeile, bevor das Chrome sie sieht.
+ *
+ * Wer hier `preventDefault` ruft, hat die Taste verbraucht; das Chrome
+ * laesst sie dann in Ruhe. So bleibt Escape zum Verlassen des Feldes da,
+ * solange niemand anderes Escape braucht.
+ */
+ onCommandKeyDown?: (event: KeyboardEvent) => void;
+ /**
+ * Ein Griff an das Eingabefeld, fuer die eine Sache, die nur es kann.
+ *
+ * Der BUG-Assistent bietet "Change symbol" an und schickt den Leser damit in
+ * dieselbe Suche, die die Kommandozeile fuehrt. Ohne diesen Griff muesste
+ * App.tsx das Feld im DOM suchen, um ihm den Fokus zu geben, und damit
+ * waere eine zweite Stelle im Programm, die weiss, wie das Chrome aufgebaut
+ * ist. Der Griff bewegt nichts und liest nichts; er zeigt nur auf das Feld.
+ */
+ commandInputRef?: RefObject;
+ /** Das Fenster ueber der Kommandozeile, wenn eines offen ist. */
+ commandOverlay?: ReactNode;
+ /** Was rechts in der Kommandozeile steht. */
+ commandHint: string;
+ /**
+ * Der Platzhalter, wenn die Zeile leer ist. Ohne Angabe die Gattung.
+ *
+ * Er kommt von aussen, weil er einen Namen aus dem geladenen Index traegt
+ * und dieses Chrome den Index nicht kennt. Dieselbe Trennung wie ueberall
+ * hier: die Anwendung weiss, was drinsteht, und das Chrome, wie es aussieht.
+ */
+ commandPlaceholder?: string;
+ /**
+ * Die anklickbaren Beispiele ueber der leeren Zeile.
+ *
+ * Sie erscheinen, solange die Zeile den Fokus hat UND leer ist, und
+ * verschwinden, sobald getippt wird: was sie zeigen, ist die Frage "wie
+ * benutzt man das", und die stellt niemand mehr, waehrend er schreibt.
+ */
+ commandExamples?: readonly CommandExample[];
+ /** Ein Beispiel in die Zeile schreiben. Nicht abschicken: man soll es aendern koennen. */
+ onCommandExample?: (text: string) => void;
+ status: Chip[];
+}
+
+function ChipView({ chip }: { chip: Chip }): JSX.Element {
+ return (
+
+ {chip.label} {chip.value}
+
+ );
+}
+
+/**
+ * Die Tab-Leiste, die scrollt statt zu kollidieren.
+ *
+ * Nutzerfeedback vom 2026-08-29: bei vielen offenen Dateien wuchs die Leiste in
+ * die Nachbarbereiche und die Beschriftungen ueberlagerten sich. Vier
+ * Entscheidungen, und keine davon ist Kosmetik:
+ *
+ * 1. **Ein Tab schrumpft nicht.** Das Kollidieren war die Vorgabe von Flexbox:
+ * ein Kind mit `flex-shrink: 1` gibt Breite her, bis sein Inhalt heraushaengt.
+ * Die Leiste laesst ihre Kinder jetzt stehen und scrollt selbst (CSS).
+ * 2. **Das Rad scrollt waagerecht.** Ueber einer waagerechten Leiste gibt es
+ * keine senkrechte Bewegung; eine Radumdrehung dort meint diese Leiste, und
+ * ein Mausrad ohne waagerechte Achse waere sonst kein Bedienweg.
+ * 3. **Ziehen ist der zweite Weg.** Trackpads koennen waagerecht wischen, Maeuse
+ * oft nicht, und eine Bildlaufleiste waere in einer 30 Pixel hohen Zeile
+ * kaum zu treffen. Gezogen wird mit gedrueckter Maustaste; ein Zug unter
+ * vier Pixeln bleibt ein Klick, sonst waere jeder Tabwechsel ein Zug.
+ * 4. **Der aktive Tab wird geholt.** Wer eine Datei oeffnet, will ihren Tab
+ * sehen, auch wenn er ausserhalb liegt.
+ */
+function TabBar(props: {
+ tabs: TabDescriptor[];
+ onSelectTab: (path: string) => void;
+ onCloseTab: (path: string) => void;
+}): JSX.Element {
+ const scroller = useRef(null);
+ const [overflow, setOverflow] = useState({ left: false, right: false, any: false });
+ const [dragging, setDragging] = useState(false);
+ const drag = useRef<{ startX: number; startScroll: number; moved: number } | null>(null);
+ const dragged = useRef(false);
+ const activePath = props.tabs.find((tab) => tab.active)?.path ?? '';
+
+ const measure = useCallback(() => {
+ const node = scroller.current;
+ if (node === null) {
+ return;
+ }
+ // Ein Pixel Toleranz: der Bildlauf endet auf Bruchteilen, und ein
+ // Indikator, der bei 0.4 Pixeln Rest noch leuchtet, luegt.
+ const room = node.scrollWidth - node.clientWidth;
+ setOverflow({
+ left: node.scrollLeft > 1,
+ right: node.scrollLeft < room - 1,
+ any: room > 1,
+ });
+ }, []);
+
+ useEffect(() => {
+ measure();
+ const node = scroller.current;
+ if (node === null || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [measure, props.tabs.length]);
+
+ /*
+ * Das Rad, als eigener Griff mit `passive: false`.
+ *
+ * React haengt Rad-Ereignisse passiv ein, und ein passiver Griff darf die
+ * Vorgabe nicht abbestellen. Ohne das Abbestellen wuerde in einer Leiste,
+ * die nur waagerecht ueberlaeuft, zweimal gescrollt: einmal von Chromium,
+ * das eine senkrechte Radbewegung dort selbst umlenkt, und einmal von hier.
+ * Der Griff bleibt trotzdem, weil diese Umlenkung nicht in jeder Engine
+ * dasselbe tut und die Leiste in allen scrollen soll.
+ */
+ useEffect(() => {
+ const node = scroller.current;
+ if (node === null) {
+ return;
+ }
+ const onWheel = (event: WheelEvent): void => {
+ const step = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
+ if (step === 0 || node.scrollWidth <= node.clientWidth) {
+ return;
+ }
+ event.preventDefault();
+ node.scrollLeft += step;
+ measure();
+ };
+ node.addEventListener('wheel', onWheel, { passive: false });
+ return () => node.removeEventListener('wheel', onWheel);
+ }, [measure]);
+
+ // Den aktiven Tab holen, sobald er wechselt.
+ useEffect(() => {
+ const node = scroller.current;
+ if (node === null || activePath.length === 0) {
+ return;
+ }
+ // Verglichen wird der Wert und nicht ein zusammengebauter Selektor: ein
+ // Dateipfad darf jedes Zeichen enthalten, und ein Selektor, der ihn
+ // einbaut, ist eine Fundstelle, an der ein Anfuehrungszeichen im
+ // Dateinamen die Suche kaputtmacht.
+ const tab = [...node.querySelectorAll('.atlas-tab')]
+ .find((candidate) => candidate.dataset.path === activePath);
+ // jsdom kennt scrollIntoView nicht. Das ist kein Grund, ihm eine
+ // Funktion unterzuschieben, die der Browser anders macht.
+ if (typeof tab?.scrollIntoView === 'function') {
+ tab.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ }
+ measure();
+ }, [activePath, measure, props.tabs.length]);
+
+ return (
+
+
+ {props.version.replace(/-dirty$/, '')}
+
+ {/*
+ * Sechs Eintraege, EINE Gestalt.
+ *
+ * Bis W7b gab es zwei: `atlas-menu-item` fuer die beiden
+ * Punkte mit eigenem Buchstaben in der Zeile und
+ * `atlas-menu-extra` fuer die vier Eintraege der Atlas-Zeile,
+ * kleiner gesetzt und mit einem gepunkteten Unterstrich statt
+ * eines Rahmens. Der Nutzer hat am 2026-08-29 genau das
+ * gemeldet: die vier sahen aus wie Text. Sie sind es nicht,
+ * also sehen sie jetzt auch nicht so aus. Eine Klasse, ein
+ * Rahmen, eine Polsterung, ein Hover, ein Fokusring, und der
+ * Klammer-Buchstabe an derselben Stelle in Phosphor.
+ */}
+ {props.onWorkspaceChange !== undefined && (
+
+ )}
+ {props.onOpenSystem !== undefined && }
+
+
+
+ {/*
+ * `data-twin` heisst seit W3 "es gibt eine rechte Spalte" und
+ * nicht mehr "es gibt einen Twin": in ihr stehen jetzt zwei
+ * Flaechen. Der Name bleibt, weil er in der CSS-Regel und in den
+ * bisherigen Beweisbildern steht, und ein umbenanntes Attribut
+ * waere eine Aenderung an allem davon fuer nichts.
+ *
+ * Seit W8 traegt der Rumpf ausserdem die drei Masse als
+ * CSS-Variablen. Sie stehen hier und nicht an den Zonen, weil sie
+ * das RASTER beschreiben und nicht die Kaesten darin: eine Spalte
+ * kennt ihre Breite nicht, ein Raster schon.
+ */}
+
+
+ {/*
+ * Die Zeile sagt, ob sie den Fokus hat.
+ *
+ * Nutzerbefund vom 2026-08-29: ohne Fokus kam nichts an, und der
+ * Zustand war der Zeile nicht anzusehen. Der Fokus faerbt jetzt den
+ * Prompt und setzt einen Rahmen (CSS ueber `data-focused`), und der
+ * Zustand steht im DOM, damit der Beweislauf ihn lesen kann statt
+ * ihn aus Farben zu raten.
+ *
+ * Kein `title` mehr am Feld: der native Tooltip legte sich beim
+ * Zeigen ueber den Anfang der Zeile. Was er sagte, steht in der
+ * Hilfe ([?]help).
+ */}
+
+
+
+ );
+}
diff --git a/graph-ui/src/app/AtlasTree.test.tsx b/graph-ui/src/app/AtlasTree.test.tsx
new file mode 100644
index 000000000..acb4915fd
--- /dev/null
+++ b/graph-ui/src/app/AtlasTree.test.tsx
@@ -0,0 +1,308 @@
+// @vitest-environment jsdom
+/*
+ * Der Explorer in jsdom: Zeilen, Pfeil-Zeichen, aktive Datei, Klickwege.
+ *
+ * Die Tastenlogik selbst ist eine reine Funktion und wird in tree-model.test.ts
+ * geprueft; hier wird nur bewiesen, dass die Liste sie ueberhaupt zu sehen
+ * bekommt, also dass sie den Fokus nehmen kann und ihre Tasten weiterreicht.
+ */
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import AtlasTree, { AtlasTreeIndexDetails, twistyFor } from './AtlasTree';
+import type { AtlasTreeProps } from './AtlasTree';
+import type { TreeRow } from './tree-model';
+
+let container: HTMLDivElement;
+let root: Root;
+async function renderIndexDetails(details: AtlasTreeProps) {
+ await act(async () => root.render());
+}
+
+beforeEach(() => {
+ (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => {
+ root.unmount();
+ });
+ container.remove();
+});
+
+const dir = (path: string, expanded: boolean, depth = 0): TreeRow => ({
+ name: path.split('/').pop() ?? path,
+ path,
+ kind: 'dir',
+ symbols: 10,
+ files: 2,
+ depth,
+ expanded,
+ loaded: true,
+});
+
+const file = (path: string, depth = 1): TreeRow => ({
+ name: path.split('/').pop() ?? path,
+ path,
+ kind: 'file',
+ symbols: 6,
+ files: 1,
+ depth,
+ expanded: false,
+ loaded: true,
+});
+
+function props(overrides: Partial = {}): AtlasTreeProps {
+ return {
+ projectName: 'atlas-sample',
+ rows: [dir('src', true), file('src/services/userService.ts')],
+ cursor: 0,
+ activePath: '',
+ note: '18 files, 71 symbols from /api/tree',
+ onCursorChange: vi.fn(),
+ onOpen: vi.fn(),
+ onToggle: vi.fn(),
+ onKeyDown: vi.fn(),
+ ...overrides,
+ };
+}
+
+async function render(next: AtlasTreeProps): Promise {
+ await act(async () => {
+ root.render();
+ });
+}
+
+const rows = (): HTMLButtonElement[] =>
+ [...container.querySelectorAll('[data-testid="atlas-tree-row"]')];
+
+describe('AtlasTree', () => {
+
+ it('zeigt EXPLORER, den Projektnamen als Wurzel und eine Zeile je Eintrag', async () => {
+ await render(props());
+ expect(container.querySelector('.atlas-tree-title')?.textContent).toBe('EXPLORER');
+ expect(container.querySelector('[data-root="true"]')?.textContent).toContain('atlas-sample/');
+ expect(rows()).toHaveLength(2);
+ });
+
+ it('haengt Ordnern einen Schraegstrich an und Dateien keinen', async () => {
+ await render(props());
+ expect(rows()[0]?.textContent).toContain('src/');
+ expect(rows()[1]?.textContent).toContain('userService.ts');
+ expect(rows()[1]?.textContent).not.toContain('userService.ts/');
+ });
+
+ it('zeigt den Zustand eines Ordners am Pfeil-Zeichen', () => {
+ expect(twistyFor(dir('src', false))).toBe('▸');
+ expect(twistyFor(dir('src', true))).toBe('▾');
+ expect(twistyFor(file('a.ts'))).toBe(' ');
+ });
+
+ it('markiert die Datei, die gerade im Reader steht', async () => {
+ await render(props({ activePath: 'src/services/userService.ts' }));
+ expect(rows()[1]?.getAttribute('data-active')).toBe('true');
+ expect(rows()[0]?.getAttribute('data-active')).toBe('false');
+ });
+
+ it('oeffnet eine Datei per Klick und klappt einen Ordner per Klick', async () => {
+ const onOpen = vi.fn();
+ const onToggle = vi.fn();
+ const onCursorChange = vi.fn();
+ await render(props({ onOpen, onToggle, onCursorChange }));
+
+ await act(async () => {
+ rows()[1]?.click();
+ });
+ expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ path: 'src/services/userService.ts' }));
+ expect(onCursorChange).toHaveBeenCalledWith(1);
+
+ await act(async () => {
+ rows()[0]?.click();
+ });
+ expect(onToggle).toHaveBeenCalledWith(expect.objectContaining({ path: 'src' }));
+ });
+
+ it('nimmt den Fokus auf die Liste und reicht Tasten weiter', async () => {
+ const onKeyDown = vi.fn();
+ await render(props({ onKeyDown }));
+ const list = container.querySelector('.atlas-tree-list');
+ expect(list?.getAttribute('tabindex')).toBe('0');
+ list?.focus();
+ expect(document.activeElement).toBe(list);
+ await act(async () => {
+ list?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ });
+ expect(onKeyDown).toHaveBeenCalled();
+ });
+
+ it('rueckt tiefere Ebenen ein, statt sie flach zu zeigen', async () => {
+ await render(props());
+ expect(rows()[0]?.style.paddingLeft).toBe('8px');
+ expect(rows()[1]?.style.paddingLeft).toBe('20px');
+ });
+
+ it('zeigt die Herkunftszeile des Baums und faerbt eine Abwesenheit', async () => {
+ await renderIndexDetails(props());
+ expect(container.querySelector('.atlas-tree-note')?.textContent).toContain('/api/tree');
+ await renderIndexDetails(props({ note: 'tree unavailable: HTTP 404', noteIsAbsence: true }));
+ expect(container.querySelector('.atlas-tree-note')?.getAttribute('data-state')).toBe('absent');
+ });
+});
+
+/*
+ * Die Coverage-Stufen im Baum.
+ *
+ * Geprueft wird, was ein Leser sieht: das Attribut, an dem die CSS-Regel
+ * haengt, das Zeichen neben dem Namen und der Tooltip. Die Farbe selbst prueft
+ * der Beweislauf im Browser; jsdom rechnet keine Kaskade.
+ */
+
+const withCoverage = (row: TreeRow, coverage: TreeRow['coverage'], reason?: string): TreeRow => ({
+ ...row,
+ coverage,
+ ...(reason === undefined ? {} : { coverageReason: reason }),
+});
+
+describe('AtlasTree und die Coverage-Stufen', () => {
+
+ const coveredRows = [
+ withCoverage(dir('src', true), 'partial'),
+ withCoverage(file('src/broken.ts'), 'partial', '12-18'),
+ withCoverage(file('src/clean.ts'), 'indexed'),
+ withCoverage(file('assets/beleg.png'), 'skipped', 'unsupported extension'),
+ withCoverage(file('secret.env'), 'not-indexed', '.gitignore'),
+ ];
+
+ it('traegt die Stufe an jeder Zeile als Attribut', async () => {
+ await render(props({ rows: coveredRows }));
+ expect(rows().map((row) => row.getAttribute('data-coverage'))).toEqual([
+ 'partial',
+ 'partial',
+ 'indexed',
+ 'skipped',
+ 'not-indexed',
+ ]);
+ });
+
+ it('gibt Dateien ein Zeichen und Ordnern einen Punkt', async () => {
+ await render(props({ rows: coveredRows }));
+ const marks = [...container.querySelectorAll('[data-testid="atlas-tree-mark"]')];
+ expect(marks.map((mark) => mark.textContent)).toEqual(['●', '!', 'x', '-']);
+ });
+
+ it('laesst eine vollstaendig indizierte Zeile ohne Zeichen', async () => {
+ await render(props({ rows: [withCoverage(file('a.ts'), 'indexed')] }));
+ expect(container.querySelector('[data-testid="atlas-tree-mark"]')).toBeNull();
+ });
+
+ /*
+ * Der Status-Punkt (W5c, Nutzerfeedback 2026-08-29).
+ *
+ * Das Zeichen sagt WELCHE Stoerung, der Punkt sagt OB eine da ist, und der
+ * Punkt steht immer da. Vorher war ein sauber indizierter Baum von einem
+ * Baum ohne Coverage-Antwort nicht zu unterscheiden.
+ */
+ it('gibt jeder Datei ihren Status-Punkt, auch der indizierten', async () => {
+ await render(props({ rows: coveredRows }));
+ const dots = [...container.querySelectorAll('[data-testid="atlas-tree-dot"]')];
+ // Vier Dateien, ein Ordner: der Ordner traegt weiter seinen Punkt als
+ // Zeichen, weil seine Aussage eine ueber seinen Inhalt ist.
+ expect(dots).toHaveLength(4);
+ expect(dots.map((dot) => dot.getAttribute('data-coverage')))
+ .toEqual(['partial', 'indexed', 'skipped', 'not-indexed']);
+ expect(dots.map((dot) => dot.getAttribute('data-tone')))
+ .toEqual(['partial', 'indexed', 'absent', 'absent']);
+ });
+
+ it('legt Status und Grund in den Tooltip des Punktes', async () => {
+ await render(props({ rows: coveredRows }));
+ /*
+ * Der Satz haengt seit W8b an der ZEILE und nicht mehr am Punkt darin.
+ * Beide trugen bis dahin denselben `title`; als eigener Tooltip waeren
+ * das zwei Kaesten fuer eine Auskunft, die sich beim Zeigen auch noch
+ * gegenseitig verdecken. Die Zusicherung ist dieselbe geblieben: Stufe
+ * und Grund sind einen Zeiger entfernt, an der Zeile, die sie betreffen.
+ */
+ expect([...container.querySelectorAll('[data-testid="atlas-tree-dot"]')]).toHaveLength(4);
+ const carrying = rows();
+ expect(carrying[1]?.getAttribute('data-hint')).toContain('partially parsed');
+ expect(carrying[1]?.getAttribute('data-hint')).toContain('12-18');
+ expect(carrying[2]?.getAttribute('data-hint')).toContain('no source recorded an issue');
+ });
+
+ it('erklaert in der Legende auch den Gutfall, mit seinem Punkt', async () => {
+ await renderIndexDetails(props({ rows: coveredRows }));
+ const good = container.querySelector('[data-testid="atlas-tree-legend-entry"][data-coverage="indexed"]');
+ expect(good).not.toBeNull();
+ expect(good?.querySelector('[data-testid="atlas-tree-legend-dot"]')?.getAttribute('data-tone'))
+ .toBe('indexed');
+ expect(good?.textContent).toContain('in the graph, no recorded issue');
+ });
+
+ it('sagt an einem Ordner, ob er aufgeklappt ist, und an einer Datei nichts', async () => {
+ await render(props({ rows: coveredRows }));
+ expect(rows()[0]?.getAttribute('data-expanded')).toBe('true');
+ expect(rows()[1]?.hasAttribute('data-expanded')).toBe(false);
+ });
+
+ it('behandelt eine Zeile ohne Stufe wie eine indizierte, ohne es zu behaupten', async () => {
+ await render(props({ rows: [file('a.ts')] }));
+ expect(rows()[0]?.getAttribute('data-coverage')).toBe('indexed');
+ expect(container.querySelector('[data-testid="atlas-tree-mark"]')).toBeNull();
+ });
+
+ it('legt den Grund des Servers in den Tooltip', async () => {
+ await render(props({ rows: coveredRows }));
+ expect(rows()[1]?.getAttribute('data-hint')).toContain('12-18');
+ expect(rows()[3]?.getAttribute('data-hint')).toContain('unsupported extension');
+ expect(rows()[4]?.getAttribute('data-hint')).toContain('.gitignore');
+ });
+
+ it('spricht im Tooltip eines Ordners ueber dessen Inhalt', async () => {
+ await render(props({ rows: coveredRows }));
+ expect(rows()[0]?.getAttribute('data-hint')).toContain('worst stage below this folder');
+ });
+
+ it('zeigt eine Legende mit den vorkommenden Stufen und dem Quellensatz', async () => {
+ await renderIndexDetails(props({ rows: coveredRows }));
+ const legend = container.querySelector('[data-testid="atlas-tree-legend"]');
+ expect(legend).not.toBeNull();
+ const entries = [...container.querySelectorAll('[data-testid="atlas-tree-legend-entry"]')]
+ .map((entry) => entry.getAttribute('data-coverage'));
+ expect(entries).toEqual(['indexed', 'partial', 'not-indexed', 'skipped', 'folder']);
+ expect(container.querySelector('[data-testid="atlas-tree-legend-source"]')?.textContent)
+ .toContain('files it never met are invisible');
+ });
+
+ it('erklaert keine Stufe, die im Baum nicht vorkommt', async () => {
+ await renderIndexDetails(props({ rows: [withCoverage(file('a.ts'), 'indexed')] }));
+ const entries = [...container.querySelectorAll('[data-testid="atlas-tree-legend-entry"]')]
+ .map((entry) => entry.getAttribute('data-coverage'));
+ expect(entries).toEqual(['indexed']);
+ });
+
+ it('zeigt jede gekappte Liste als eigene ehrliche Zeile', async () => {
+ await renderIndexDetails(props({
+ rows: coveredRows,
+ truncations: ['the server cut the skipped list: 812 recorded, fewer listed'],
+ }));
+ const cut = [...container.querySelectorAll('[data-testid="atlas-tree-truncation"]')];
+ expect(cut).toHaveLength(1);
+ expect(cut[0]?.textContent).toContain('812 recorded');
+ expect(cut[0]?.getAttribute('data-state')).toBe('absent');
+ });
+
+ it('oeffnet eine uebersprungene Datei wie jede andere, statt sie zu sperren', async () => {
+ // Was danach passiert, entscheidet der Reader. Eine Zeile, die auf
+ // einen Klick nicht reagiert, waere eine tote Flaeche ohne Erklaerung.
+ const onOpen = vi.fn();
+ await render(props({ rows: coveredRows, onOpen }));
+ await act(async () => {
+ rows()[3]?.click();
+ });
+ expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ path: 'assets/beleg.png' }));
+ });
+});
diff --git a/graph-ui/src/app/AtlasTree.tsx b/graph-ui/src/app/AtlasTree.tsx
new file mode 100644
index 000000000..8e0dbda6f
--- /dev/null
+++ b/graph-ui/src/app/AtlasTree.tsx
@@ -0,0 +1,305 @@
+/**
+ * Der EXPLORER: der Datei-Baum aus der Vereinigung von Graph und Coverage, mit
+ * Maus und mit Tastatur.
+ *
+ * Rein darstellend. Die Zeilen kommen fertig ausgerechnet herein
+ * (flattenTree und mergeCoverageIntoLevels in tree-model.ts), das Nachladen
+ * einer Ebene macht der Aufrufer. Diese Trennung ist der Grund, warum der Baum
+ * ohne Server pruefbar ist: was hier steht, ist Anordnung und Tastenlogik, und
+ * beides braucht kein /api.
+ *
+ * Tastatur ist Pflicht, nicht Zugabe.
+ * Die Liste selbst nimmt den Fokus, die Zeilen tragen einen wandernden Cursor.
+ * Waeren die Zeilen einzeln fokussierbar, muesste man sich durch einen ganzen
+ * Baum tabben, um an den Editor zu kommen.
+ *
+ * Seit W4d traegt jede Zeile ihre Coverage-Stufe, und die ist sichtbar statt
+ * nur vorhanden. Drei Entscheidungen dazu:
+ *
+ * 1. **Dateien tragen ein Zeichen, Ordner einen Punkt.** Der Marker einer Datei
+ * ist eine Aussage ueber sie selbst, der eines Ordners eine ueber seinen
+ * Inhalt. Dasselbe Zeichen fuer beides waere die Behauptung, der Ordner
+ * selbst sei uebersprungen worden.
+ * 2. **Gedimmt wird, was keinen Inhalt hat.** `skipped`, `not-indexed` und
+ * `ignored` sind Zeilen, hinter denen dieses Produkt nichts zeigen kann.
+ * Sie ganz wegzulassen waere die Behauptung, es gebe die Datei nicht; sie
+ * normal zu zeigen waere die Behauptung, ein Klick fuehre irgendwohin.
+ * 3. **Die Legende zeigt nur Stufen, die vorkommen.** Eine Legende, die vier
+ * Stufen erklaert, von denen drei nirgends stehen, ist eine Legende fuer
+ * einen anderen Baum. Der ehrliche Quellensatz steht dagegen immer da: er
+ * handelt von dem, was NICHT im Baum steht, und ist genau dann noetig, wenn
+ * der Baum vollstaendig aussieht.
+ *
+ * Seit W5c kommt eine vierte dazu, und sie korrigiert die zweite:
+ *
+ * 4. **Jede Datei traegt IMMER ihren Status-Punkt, auch der Gutfall.** Bis
+ * dahin blieb eine indizierte Zeile unmarkiert, und ein sauberer Baum sah
+ * aus wie ein Baum, zu dem gar keine Coverage-Antwort da war. Der
+ * Unterschied zwischen "nichts gemeldet" und "nichts gefragt" ist genau der,
+ * den dieses Produkt sonst ueberall benennt. Das Buchstaben-Zeichen bleibt,
+ * wo es war: es traegt WELCHE Stoerung, der Punkt traegt OB eine da ist.
+ */
+import type { JSX, KeyboardEvent } from 'react';
+import { useEffect, useRef } from 'react';
+import { COVERAGE_ORDER } from './tree-model';
+import type { CoverageState, TreeRow } from './tree-model';
+import { messages } from '../i18n/messages';
+import { workspaceStrings } from './workspace-strings';
+import Hint from '../ui/tooltip/Hint';
+import {
+ COVERAGE_DESCRIPTIONS,
+ COVERAGE_FOLDER_MARK,
+ COVERAGE_LABELS,
+ COVERAGE_MARKS,
+ COVERAGE_SOURCE_NOTE,
+ coverageTooltip,
+ folderTooltip,
+} from './coverage-strings';
+
+export interface AtlasTreeProps {
+ explained?: boolean;
+ /** Der Projektname als Wurzelzeile, so wie das Vorbild ihn zeigt. */
+ projectName: string;
+ rows: TreeRow[];
+ /** Zeile unter dem Tastatur-Cursor. */
+ cursor: number;
+ /** Pfad der Datei, die gerade im Reader steht. */
+ activePath: string;
+ /** Ehrliche Zeile unter dem Baum: was der Server geliefert hat und was nicht. */
+ note: string;
+ /** True, wenn die Notiz eine Abwesenheit benennt und nicht nur eine Zahl. */
+ noteIsAbsence?: boolean;
+ /**
+ * Listen, die der Server gekappt hat, je eine Zeile.
+ *
+ * Getrennt von `note`, weil es eine andere Art von Aussage ist: die Notiz
+ * beschreibt, was da ist, diese Zeilen beschreiben, was der Server selbst
+ * als unvollstaendig gemeldet hat.
+ */
+ truncations?: readonly string[];
+ onCursorChange: (index: number) => void;
+ onOpen: (row: TreeRow) => void;
+ onToggle: (row: TreeRow) => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+}
+
+/** Das Pfeil-Zeichen einer Zeile. Dateien tragen keins. */
+export function twistyFor(row: TreeRow): string {
+ if (row.kind !== 'dir') {
+ return ' ';
+ }
+ return row.expanded ? '▾' : '▸';
+}
+
+/**
+ * Die Stufe einer Zeile, mit einem Vorgabewert, der nichts behauptet.
+ *
+ * Eine Zeile ohne Stufe ist eine Zeile, zu der noch keine Coverage-Antwort da
+ * war. Sie wird wie `indexed` gezeichnet, weil der Graph sie genannt hat, und
+ * traegt darum kein Zeichen.
+ */
+export function coverageOf(row: TreeRow): CoverageState {
+ return row.coverage ?? 'indexed';
+}
+
+/** Das Zeichen einer Zeile: Punkt fuer Ordner, Buchstabe fuer Dateien. */
+export function markFor(row: TreeRow): string {
+ const state = coverageOf(row);
+ if (state === 'indexed') {
+ return '';
+ }
+ return row.kind === 'dir' ? COVERAGE_FOLDER_MARK : COVERAGE_MARKS[state];
+}
+
+/**
+ * Die drei Toene, die ein Status-Punkt tragen kann.
+ *
+ * Drei und nicht fuenf: der Punkt beantwortet die Frage "steht hinter dieser
+ * Datei etwas im Graphen", und darauf gibt es drei Antworten. Welcher der drei
+ * Gruende die dritte ausgeloest hat, sagt der Tooltip und die Legende; eine
+ * eigene Farbe je Grund waere eine Palette, die niemand mehr auswendig kann.
+ */
+export function dotToneOf(state: CoverageState): 'indexed' | 'partial' | 'absent' {
+ if (state === 'indexed') {
+ return 'indexed';
+ }
+ return state === 'partial' ? 'partial' : 'absent';
+}
+
+/** Der Tooltip einer Zeile. */
+export function titleFor(row: TreeRow): string {
+ const state = coverageOf(row);
+ return row.kind === 'dir' ? folderTooltip(state) : coverageTooltip(state, row.coverageReason ?? '');
+}
+
+/** Die Stufen, die in diesen Zeilen wirklich vorkommen, in ihrer Ordnung. */
+export function shownStates(rows: readonly TreeRow[]): CoverageState[] {
+ const present = new Set(['indexed']);
+ for (const row of rows) {
+ present.add(coverageOf(row));
+ }
+ return COVERAGE_ORDER.filter((state) => present.has(state));
+}
+
+export default function AtlasTree(props: AtlasTreeProps): JSX.Element {
+ const listRef = useRef(null);
+
+ // Den Cursor im Blick behalten. Ohne das wandert er beim Tippen aus dem
+ // sichtbaren Bereich und der Baum sieht aus, als reagiere er nicht.
+ useEffect(() => {
+ const list = listRef.current;
+ if (list === null) {
+ return;
+ }
+ const row = list.querySelector('[data-cursor="true"]');
+ // jsdom kennt scrollIntoView nicht. Das ist kein Grund, den Baum nicht
+ // zu pruefen, und kein Grund, jsdom eine Funktion unterzuschieben, die
+ // der Browser anders macht.
+ if (typeof row?.scrollIntoView === 'function') {
+ row.scrollIntoView({ block: 'nearest' });
+ }
+ }, [props.cursor, props.rows.length]);
+
+ return (
+
+ );
+}
+
+/** Coverage metadata belongs in the Coverage workspace, separate from file navigation. */
+export function AtlasTreeIndexDetails(props: Pick): JSX.Element {
+ const states = shownStates(props.rows);
+ const markedFolders = props.rows.some((row) => row.kind === 'dir' && coverageOf(row) !== 'indexed');
+ return
+ {props.note.length > 0 && (
+
+ {/*
+ * Die Legende erklaert auch den Gutfall.
+ *
+ * Eine Legende, die nur die Stoerungen erklaert, laesst den
+ * haeufigsten Punkt des Baums unerklaert und macht aus einer
+ * Antwort ein Raetsel. Der Punkt steht darum in jeder Zeile
+ * mit, in genau dem Ton, den die Baumzeile traegt.
+ */}
+ {states.map((state) => (
+
+
+
+ {COVERAGE_MARKS[state].length > 0 ? COVERAGE_MARKS[state] : ' '}
+
+ {COVERAGE_LABELS[state]} {COVERAGE_DESCRIPTIONS[state]}
+
+ ))}
+ {markedFolders && (
+
+
+ {COVERAGE_FOLDER_MARK}
+
+ {messages.explorer.folderLabel} {messages.explorer.folderDescription}
+
+ )}
+
+ {COVERAGE_SOURCE_NOTE}
+
+
;
+}
diff --git a/graph-ui/src/app/agent-status.css b/graph-ui/src/app/agent-status.css
new file mode 100644
index 000000000..4f1a442a2
--- /dev/null
+++ b/graph-ui/src/app/agent-status.css
@@ -0,0 +1,56 @@
+.atlas-agent-controls {
+ display: inline-flex;
+ align-items: stretch;
+ flex-shrink: 0;
+ border: 1px solid var(--atlas-line);
+ border-radius: 5px;
+}
+
+.atlas-agent-controls .atlas-browser-ai-action {
+ position: relative;
+ justify-content: center;
+ min-width: 36px;
+ padding: 6px 8px;
+ border: 0;
+ border-radius: 5px 0 0 5px;
+}
+
+.atlas-agent-controls .atlas-browser-ai-action:hover {
+ background: var(--atlas-select);
+ color: var(--atlas-fg);
+}
+
+.atlas-agent-controls .atlas-browser-ai-action > span {
+ position: absolute;
+ top: 4px;
+ right: 5px;
+ width: 7px;
+ height: 7px;
+ box-shadow: 0 0 0 2px var(--atlas-bg);
+}
+
+.atlas-agent-controls .atlas-browser-ai-action[data-state=active] > span {
+ box-shadow: 0 0 0 2px var(--atlas-bg), 0 0 8px var(--atlas-glow);
+}
+
+.atlas-agent-chat-toggle {
+ display: grid;
+ place-items: center;
+ min-width: 36px;
+ padding: 6px 8px;
+ border: 0;
+ border-left: 1px solid var(--atlas-line);
+ border-radius: 0 5px 5px 0;
+ background: transparent;
+ color: var(--atlas-dim);
+ cursor: pointer;
+}
+
+.atlas-agent-chat-toggle:hover, .atlas-agent-chat-toggle[aria-expanded="true"] {
+ background: var(--atlas-select);
+ color: var(--atlas-phosphor);
+}
+
+.atlas-agent-chat-toggle-pane { fill: transparent; }
+.atlas-agent-chat-toggle[aria-expanded="true"] .atlas-agent-chat-toggle-pane { fill: currentColor; opacity: .3; }
+.atlas-agent-controls button:focus-visible { outline: 2px solid var(--atlas-phosphor); outline-offset: 2px; }
diff --git a/graph-ui/src/app/atlas-api.test.ts b/graph-ui/src/app/atlas-api.test.ts
new file mode 100644
index 000000000..5d1c18a9e
--- /dev/null
+++ b/graph-ui/src/app/atlas-api.test.ts
@@ -0,0 +1,163 @@
+/*
+ * The /api client of the projects panel: which route, which method, which
+ * body each call sends, and what it makes of the answer. A recording fetch
+ * stands in for the server; nothing here touches the network.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { AtlasApi, AtlasApiError } from './atlas-api';
+import { observeErrors, type ErrorReport } from '../provider/error-observer';
+
+interface FetchCall {
+ url: string;
+ init: RequestInit | undefined;
+}
+
+function recordingFetch(
+ reply: { ok?: boolean; status?: number; body: string },
+ calls: FetchCall[],
+): typeof globalThis.fetch {
+ return ((url: string, init?: RequestInit) => {
+ calls.push({ url, init });
+ return Promise.resolve({
+ ok: reply.ok ?? true,
+ status: reply.status ?? 200,
+ statusText: '',
+ text: () => Promise.resolve(reply.body),
+ });
+ }) as unknown as typeof globalThis.fetch;
+}
+
+function api(reply: { ok?: boolean; status?: number; body: string }): { api: AtlasApi; calls: FetchCall[] } {
+ const calls: FetchCall[] = [];
+ return { api: new AtlasApi({ base: 'http://127.0.0.1:9749', fetch: recordingFetch(reply, calls) }), calls };
+}
+
+describe('AtlasApi, the projects routes', () => {
+ it.each(['', 'http://127.0.0.1:9749'])('attributes failed requests to their decoded project with base %s', async (base) => {
+ const reports: ErrorReport[] = [];
+ const stopObserving = observeErrors((report) => reports.push(report));
+ const calls: FetchCall[] = [];
+ const client = new AtlasApi({
+ base,
+ fetch: recordingFetch({ ok: false, status: 503, body: '{"error":"unavailable"}' }, calls),
+ });
+ try {
+ await expect(client.logs(20, 'warn', 'a repo&revision=2')).rejects.toBeInstanceOf(AtlasApiError);
+ expect(calls[0]?.url).toBe(`${base}/api/logs?lines=20&min_level=warn&project=a+repo%26revision%3D2`);
+ expect(reports).toEqual([expect.objectContaining({
+ source: 'api', level: 'error', project: 'a repo&revision=2',
+ })]);
+ } finally {
+ stopObserving();
+ }
+ });
+
+ it('does not invent a project for failed requests without one', async () => {
+ const reports: ErrorReport[] = [];
+ const stopObserving = observeErrors((report) => reports.push(report));
+ const { api: client } = api({ ok: false, status: 503, body: '{"error":"unavailable"}' });
+ try {
+ await expect(client.logs(20)).rejects.toBeInstanceOf(AtlasApiError);
+ expect(reports).toEqual([expect.objectContaining({ project: '' })]);
+ } finally {
+ stopObserving();
+ }
+ });
+
+ it('encodes history filters without interpreting search as URL parameters', async () => {
+ const { api: client, calls } = api({ body: '{"lines":[],"total":0,"query":"path&level=info"}' });
+ const result = await client.logs(200, 'error', undefined, { scope: 'unattributed', query: 'path&level=info' });
+ const url = new URL(calls[0]!.url);
+ expect(url.searchParams.get('scope')).toBe('unattributed');
+ expect(url.searchParams.get('min_level')).toBe('error');
+ expect(url.searchParams.get('q')).toBe('path&level=info');
+ expect(url.searchParams.has('level')).toBe(false);
+ expect(result.query).toBe('path&level=info');
+ });
+
+ it('starts an index job with a JSON body over POST', async () => {
+ const { api: client, calls } = api({ status: 202, body: '{"status":"indexing","slot":1,"path":"/repo"}' });
+ const started = await client.startIndex('/repo', 'repo');
+ expect(started).toEqual({ slot: 1, path: '/repo' });
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/index');
+ expect(calls[0]?.init?.method).toBe('POST');
+ expect(calls[0]?.init?.body).toBe('{"root_path":"/repo","project_name":"repo"}');
+ expect((calls[0]?.init?.headers as Record)['Content-Type']).toBe('application/json');
+ });
+
+ it('reads the job table', async () => {
+ const { api: client, calls } = api({ body: '[{"slot":0,"status":"done","path":"/a","error":""}]' });
+ const jobs = await client.indexJobs();
+ expect(jobs).toEqual([{ slot: 0, status: 'done', path: '/a', error: '' }]);
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/index-status');
+ expect(calls[0]?.init?.method).toBe('GET');
+ expect(calls[0]?.init?.body).toBeUndefined();
+ });
+
+ it('browses with the path in the query, and without one for the server root', async () => {
+ const { api: client, calls } = api({ body: '{"path":"/Users/x","dirs":["a"],"parent":"/Users"}' });
+ const level = await client.browse('/Users/x');
+ expect(level.dirs).toEqual(['a']);
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/browse?path=%2FUsers%2Fx');
+ await client.browse('');
+ expect(calls[1]?.url).toBe('http://127.0.0.1:9749/api/browse');
+ });
+
+ it('deletes over DELETE with the name in the query', async () => {
+ const { api: client, calls } = api({ body: '{"deleted":true}' });
+ await client.deleteProject('my project');
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/project?name=my+project');
+ expect(calls[0]?.init?.method).toBe('DELETE');
+ });
+
+ it('reads the health verdict', async () => {
+ const { api: client, calls } = api({ body: '{"status":"healthy","nodes":1,"edges":2,"size_bytes":3,"indexed_at":"2026-10-01T12:00:00Z","watch_registered":true,"watcher_running":false}' });
+ expect(await client.projectHealth('p')).toEqual({ status: 'healthy', nodes: 1, edges: 2, sizeBytes: 3, indexedAt: '2026-10-01T12:00:00Z', watchRegistered: true, watcherRunning: false, reason: '' });
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/project-health?name=p');
+ });
+
+ it('reads and stores the decision record', async () => {
+ const { api: client, calls } = api({ body: '{"has_adr":true,"content":"# x","updated_at":"now"}' });
+ expect(await client.adr('p')).toEqual({ hasAdr: true, content: '# x', updatedAt: 'now' });
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/adr?project=p');
+ await client.saveAdr('p', '# y');
+ expect(calls[1]?.url).toBe('http://127.0.0.1:9749/api/adr');
+ expect(calls[1]?.init?.method).toBe('POST');
+ expect(calls[1]?.init?.body).toBe('{"project":"p","content":"# y"}');
+ });
+
+ it('reads the log tail and the process report', async () => {
+ const { api: client, calls } = api({ body: '{"lines":["a"],"total":9}' });
+ expect(await client.logs(200)).toEqual({ lines: ['a'], total: 9 });
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/logs?lines=200');
+ const processes = api({ body: '{"self_pid":7,"self_rss_mb":1.5,"processes":[]}' });
+ expect(await processes.api.processes()).toEqual({
+ selfPid: 7, selfRssMb: 1.5, processes: [],
+ telemetry: { cpuUnit: 'unknown', memoryKind: 'unknown', selfMemoryKind: 'unknown', selfMemoryMb: 1.5 },
+ });
+ expect(processes.calls[0]?.url).toBe('http://127.0.0.1:9749/api/processes');
+ });
+
+ it('reads the frontend log tail with its file', async () => {
+ const { api: client, calls } = api({
+ body: '{"path":"/c/logs/ui.log","previous_path":"/c/logs/ui.log.1","size_bytes":77,"partial":true,"lines":["{}"],"total":3}',
+ });
+ expect(await client.uiLogTail(50)).toEqual({
+ path: '/c/logs/ui.log',
+ previousPath: '/c/logs/ui.log.1',
+ sizeBytes: 77,
+ partial: true,
+ lines: ['{}'],
+ total: 3,
+ });
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/ui-log?lines=50');
+ });
+
+ it('carries the status of a refusal, so busy can be told from forbidden', async () => {
+ const { api: client } = api({ ok: false, status: 423, body: '{"error":"project is busy; retry after indexing"}' });
+ await expect(client.saveAdr('p', 'x')).rejects.toSatisfy((error: unknown) =>
+ error instanceof AtlasApiError && error.status === 423 && error.route === '/api/adr');
+ });
+});
diff --git a/graph-ui/src/app/atlas-api.ts b/graph-ui/src/app/atlas-api.ts
new file mode 100644
index 000000000..316303e8f
--- /dev/null
+++ b/graph-ui/src/app/atlas-api.ts
@@ -0,0 +1,292 @@
+/**
+ * Die /api-Routen des Servers, so weit dieses Chrome sie braucht.
+ *
+ * Zwei Wege fuehren zum Server und sie sind nicht dasselbe: `/rpc` traegt die
+ * MCP-Werkzeuge (src/provider/rpc-client.ts), `/api` traegt die fertig
+ * gerechneten Atlas-Antworten. Der Datei-Baum ist eine davon, und er kommt
+ * ausdruecklich aus dem Graphen und nicht vom Dateisystem: was hier nicht
+ * steht, hat der Index nicht gesehen.
+ *
+ * Same-origin ist der Normalfall. Der Server nimmt `/api` nur an, wenn der
+ * Host-Kopf genau seine eigene Loopback-Adresse nennt und der Origin-Kopf
+ * entweder fehlt oder dieselbe Adresse nennt. Ein
+ * `base` ist darum nur fuer Node-Aufrufer da, die keinen Origin senden.
+ */
+
+import { hasErrorObservers, reportError } from '../provider/error-observer';
+import { readConfigSnapshot, type ConfigSnapshot } from '../settings/config-model';
+import { readTreeLevel } from './tree-model';
+import type { TreeLevel } from './tree-model';
+import {
+ readFlowDetail,
+ readFlowSummaries,
+ readTraceAnswer,
+} from '../traces/trace-schemas';
+import type { FlowDetail, FlowSummary, TraceAnswer } from '../traces/trace-schemas';
+import {
+ readAdr,
+ readBrowse,
+ readHealth,
+ readIndexJobs,
+ readIndexStarted,
+ readLogs,
+ readProcesses,
+ readUiLogTail,
+} from '../projects/projects-model';
+import type {
+ AdrRecord,
+ BrowseLevel,
+ IndexJob,
+ IndexStarted,
+ LogTail,
+ ProcessReport,
+ ProjectHealth,
+ UiLogTail,
+} from '../projects/projects-model';
+
+export interface AtlasApiOptions {
+ /** Ursprung des Servers, ohne Schraegstrich am Ende. Leer heisst same-origin. */
+ base?: string;
+ /** Ersetzbares fetch, damit Tests ohne Netz laufen. */
+ fetch?: typeof globalThis.fetch;
+ /** Abbruchsignal fuer jeden Aufruf: das Projektfenster bricht so beim Wechsel ab (K24). */
+ signal?: AbortSignal;
+}
+
+/** Was `/api/repo-info` ueber ein Projekt weiss. Leere Felder heissen "unbekannt". */
+export interface RepoInfo {
+ rootPath: string;
+ branch: string;
+ remoteUrl: string;
+}
+
+/** Ein Fehler einer /api-Route, mit dem Status, den sie geliefert hat. */
+export class AtlasApiError extends Error {
+ constructor(
+ readonly route: string,
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = 'AtlasApiError';
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Announce a failed /api call before it is thrown (provider/error-observer.ts),
+ * so the frontend log holds it even though the panel catches it and shows a
+ * sentence instead. A 4xx is the server saying no to this request (warn);
+ * no answer, a 5xx or an unreadable body is a failure (error).
+ */
+function announce(error: AtlasApiError, detail?: string): AtlasApiError {
+ if (hasErrorObservers()) {
+ const refused = error.status >= 400 && error.status < 500;
+ reportError({
+ source: 'api',
+ project: new URL(error.route, 'http://127.0.0.1').searchParams.get('project') ?? '',
+ level: refused ? 'warn' : 'error',
+ message: error.message,
+ detail,
+ });
+ }
+ return error;
+}
+
+const text = (value: unknown): string => (typeof value === 'string' ? value : '');
+
+export class AtlasApi {
+
+ constructor(private readonly options: AtlasApiOptions = {}) { }
+
+ async configuration(): Promise {
+ return readConfigSnapshot(await this.getJson('/api/config'));
+ }
+
+ async saveConfiguration(revision: string, changes: Record): Promise {
+ return readConfigSnapshot(await this.request('POST', '/api/config', { revision, changes }));
+ }
+
+ private getJson(route: string): Promise {
+ return this.request('GET', route);
+ }
+
+ /**
+ * One request, any method. The writing routes of the projects panel
+ * (POST /api/index, POST /api/adr, DELETE /api/project) go through here
+ * with a JSON body; the error carries the status so a caller can tell
+ * "busy, try later" (423) from "refused" (403) from "not there" (404).
+ */
+ private async request(method: 'GET' | 'POST' | 'DELETE', route: string, payload?: unknown): Promise {
+ const url = `${this.options.base ?? ''}${route}`;
+ const doFetch = this.options.fetch ?? globalThis.fetch;
+ let response: Response;
+ try {
+ const init: RequestInit = { method, headers: { Accept: 'application/json' } };
+ if (payload !== undefined) {
+ init.headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
+ init.body = JSON.stringify(payload);
+ }
+ if (this.options.signal !== undefined) init.signal = this.options.signal;
+ response = await doFetch(url, init);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ const failure = new AtlasApiError(route, 0, `${route} war nicht erreichbar: ${message}`);
+ // Ein abgebrochener Aufruf ist die Sache des Aufrufers und kein Fehler des Servers.
+ if (this.options.signal?.aborted) throw failure;
+ throw announce(failure);
+ }
+ const body = await response.text();
+ if (!response.ok) {
+ throw announce(
+ new AtlasApiError(route, response.status, `${route} antwortete mit HTTP ${response.status}: ${body.slice(0, 200)}`),
+ body.slice(0, 400),
+ );
+ }
+ try {
+ return JSON.parse(body) as unknown;
+ } catch {
+ throw announce(
+ new AtlasApiError(route, response.status, `${route} lieferte kein JSON: ${body.slice(0, 200)}`),
+ body.slice(0, 400),
+ );
+ }
+ }
+
+ /**
+ * Eine Ebene des Datei-Baums. `path` leer heisst Projektwurzel.
+ *
+ * Der Server antwortet je Anfrage mit genau einer Ebene. Der Explorer
+ * fragt darum je aufgeklapptem Ordner einmal nach, statt einen ganzen Baum
+ * zu erwarten, den es auf dieser Route nicht gibt.
+ */
+ async tree(project: string, path = ''): Promise {
+ const query = new URLSearchParams({ project });
+ if (path.length > 0) {
+ query.set('path', path);
+ }
+ return readTreeLevel(await this.getJson(`/api/tree?${query.toString()}`));
+ }
+
+ /**
+ * Der kuerzeste Weg, den der Index von `from` nach `to` kennt, mit dem, was
+ * davon beobachtet wurde.
+ *
+ * `mode` bleibt `calls`: `data` laeuft ueber DATA_FLOWS-Kanten, und die
+ * schreibt die Analyse fuer TypeScript nicht. Die genaue Bedeutung jeder
+ * Antwortform steht in src/traces/bug-paths.ts, wo sie gelesen wird.
+ */
+ async trace(project: string, from: string, to: string): Promise {
+ const query = new URLSearchParams({ project, from, to, mode: 'calls' });
+ return readTraceAnswer(await this.getJson(`${TRACE_ROUTE}?${query.toString()}`));
+ }
+
+ /** Die gereihten Ablaeufe des Projekts, ohne ihre Schritte. */
+ async flows(project: string): Promise {
+ const query = new URLSearchParams({ project });
+ return readFlowSummaries(await this.getJson(`${FLOWS_ROUTE}?${query.toString()}`));
+ }
+
+ /** Die Schritte eines Ablaufs, mit dem, was davon beobachtet wurde. */
+ async flow(project: string, id: number): Promise {
+ const query = new URLSearchParams({ project, id: String(id) });
+ return readFlowDetail(await this.getJson(`${FLOW_ROUTE}?${query.toString()}`));
+ }
+
+ // ------------------------------------------------ the projects panel --
+
+ /** The folders under `path`; the server's own roots when `path` is empty. */
+ async browse(path = ''): Promise {
+ const query = new URLSearchParams();
+ if (path.length > 0) {
+ query.set('path', path);
+ }
+ const suffix = path.length > 0 ? `?${query.toString()}` : '';
+ return readBrowse(await this.getJson(`/api/browse${suffix}`));
+ }
+
+ /** Ask the server to index `rootPath` under `projectName`, in the background. */
+ async startIndex(rootPath: string, projectName: string): Promise {
+ return readIndexStarted(
+ await this.request('POST', '/api/index', { root_path: rootPath, project_name: projectName }),
+ );
+ }
+
+ /** Every index job the server has run since it started, with its state. */
+ async indexJobs(): Promise {
+ return readIndexJobs(await this.getJson('/api/index-status'));
+ }
+
+ /** Remove the index file of a project. The repository itself is not touched. */
+ async deleteProject(name: string): Promise {
+ await this.request('DELETE', `/api/project?${new URLSearchParams({ name }).toString()}`);
+ }
+
+ /** Does the index file open, and what does it hold. */
+ async projectHealth(name: string): Promise {
+ return readHealth(await this.getJson(`/api/project-health?${new URLSearchParams({ name }).toString()}`));
+ }
+
+ /** The decision record the server keeps for a project. */
+ async adr(project: string): Promise {
+ return readAdr(await this.getJson(`/api/adr?${new URLSearchParams({ project }).toString()}`));
+ }
+
+ /** Store the decision record of a project. */
+ async saveAdr(project: string, content: string): Promise {
+ await this.request('POST', '/api/adr', { project, content });
+ }
+
+ /** The last `lines` lines of the server log. */
+ async logs(lines: number, minLevel?: 'debug' | 'info' | 'warn' | 'error', project?: string, filters?: { scope?: 'unattributed'; query?: string }): Promise {
+ const query = new URLSearchParams({ lines: String(lines) });
+ if (minLevel) query.set('min_level', minLevel);
+ if (project !== undefined) query.set('project', project);
+ if (filters?.scope) query.set('scope', filters.scope);
+ if (filters?.query?.trim()) query.set('q', filters.query.trim());
+ return readLogs(await this.getJson(`/api/logs?${query.toString()}`));
+ }
+
+ /** The codebase-memory-mcp processes on this machine, as the server sees them. */
+ async processes(): Promise {
+ return readProcesses(await this.getJson('/api/processes'));
+ }
+
+ /**
+ * The tail of the frontend's own log file on the server, the one this
+ * page writes through POST /api/ui-log (app/ui-log.ts). The answer names
+ * the file, so the panel can tell a reader what to attach to a report.
+ */
+ async uiLogTail(lines: number): Promise {
+ return readUiLogTail(await this.getJson(`/api/ui-log?${new URLSearchParams({ lines: String(lines) }).toString()}`));
+ }
+
+ /** Wurzelpfad, Branch und Remote eines Projekts. Leere Felder bleiben leer. */
+ async repoInfo(project: string): Promise {
+ const raw = await this.getJson(`/api/repo-info?${new URLSearchParams({ project }).toString()}`);
+ const record = isRecord(raw) ? raw : {};
+ return {
+ rootPath: text(record['root_path']),
+ branch: text(record['branch']),
+ remoteUrl: text(record['remote_url']),
+ };
+ }
+}
+
+/** Der Name der Route, aus der der Baum kommt. Der Beweislauf schreibt ihn mit. */
+export const TREE_ROUTE = '/api/tree';
+
+/**
+ * Die drei Routen, ueber die Beobachtetes lesbar ist.
+ *
+ * Es gibt keine vierte: `observed_calls` wird ausschliesslich von
+ * `cbm_atlas_attach_observed` an eine Trace- oder Flow-Antwort geheftet
+ * (cbm/src/ui/http_server.c). Was das fuer den BUG-Assistenten bedeutet, steht
+ * im Kopf von src/traces/bug-paths.ts.
+ */
+export const TRACE_ROUTE = '/api/trace';
+export const FLOWS_ROUTE = '/api/flows';
+export const FLOW_ROUTE = '/api/flow';
diff --git a/graph-ui/src/app/build-info.ts b/graph-ui/src/app/build-info.ts
new file mode 100644
index 000000000..de45e9dfa
--- /dev/null
+++ b/graph-ui/src/app/build-info.ts
@@ -0,0 +1,44 @@
+/**
+ * Was der Build ueber sich selbst weiss.
+ *
+ * Die Version wird zur Buildzeit hineingeschrieben (vite.config.ts, `define`),
+ * nicht zur Laufzeit erfragt: eine Oberflaeche, die im Browser nach ihrer
+ * eigenen Version fragt, kann nur eine Antwort bekommen, die jemand anders
+ * gebaut hat. Der Zusatz `-dirty` steht dran, wenn der Arbeitsbaum beim Bauen
+ * nicht sauber war, und er ist genau dann wichtig, wenn ein Screenshot zu einer
+ * Fassung gehoeren soll, die es als Commit nicht gibt.
+ */
+
+declare const __ATLAS_VERSION__: string;
+
+/**
+ * Was der Build zur Bauzeit ueber sich geschrieben hat: `v0.0.1`, `v0.0.1-dirty`
+ * oder, ohne Vite, der Rueckfall. Er sagt `dev` statt eine Zahl zu erfinden.
+ */
+const stamped: string =
+ typeof __ATLAS_VERSION__ === 'string' && __ATLAS_VERSION__.length > 0
+ ? __ATLAS_VERSION__
+ : 'v0.0.0-dev';
+
+const dash = stamped.indexOf('-');
+
+/**
+ * Die Version allein, in der Form `v0.0.1`.
+ *
+ * Getrennt vom Zusatz, und das ist eine Entscheidung mit einem Grund: der Chip
+ * beantwortet die Frage "welche Fassung ist das", und darauf ist `v0.0.1` die
+ * Antwort. `v0.0.1-dirty` beantwortet zwei Fragen in einem Wort, und die zweite
+ * ("stand beim Bauen etwas uncommitted") ist eine Aussage ueber den
+ * Arbeitsbaum, nicht ueber die Fassung. Die Aussage verschwindet nicht, sie
+ * bekommt ihr eigenes Element daneben (`ATLAS_BUILD_SUFFIX`).
+ */
+export const ATLAS_VERSION: string = dash === -1 ? stamped : stamped.slice(0, dash);
+
+/**
+ * Der Zusatz hinter der Version: `dirty`, `dev`, oder leer.
+ *
+ * Leer heisst "aus einem sauberen Baum gebaut" und nicht "keine Aussage": ohne
+ * git faellt der Zusatz beim Bauen schon weg (vite.config.ts), und dieses Modul
+ * erfindet nichts dazu.
+ */
+export const ATLAS_BUILD_SUFFIX: string = dash === -1 ? '' : stamped.slice(dash + 1);
diff --git a/graph-ui/src/app/coverage-source.test.ts b/graph-ui/src/app/coverage-source.test.ts
new file mode 100644
index 000000000..f3d856cd6
--- /dev/null
+++ b/graph-ui/src/app/coverage-source.test.ts
@@ -0,0 +1,211 @@
+/*
+ * Die Coverage-Quelle: wer wie oft gefragt wird.
+ *
+ * Der Client wird durch einen Doppelgaenger ersetzt, der jede Anfrage
+ * mitschreibt. Gefragt wird nicht, ob die Antworten richtig gelesen werden
+ * (das steht in tree-model.test.ts), sondern ob paginiert wird, wann
+ * aufgehoert wird, und ob ein Fehler ein Fehler bleibt statt ein leerer
+ * Befund zu werden.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import {
+ COVERAGE_MAX_PAGES,
+ COVERAGE_ROOT_SCOPE,
+ COVERAGE_SCOPE_LIMIT,
+ loadCoverage,
+ loadPathCoverage,
+} from './coverage-source';
+import type { RpcIntelligenceClient } from '../provider/rpc-client';
+
+const STATUS = {
+ project: 'p',
+ parse_partial: { files: [{ path: 'src/a.ts', error_ranges: '4-9' }], count: 1, truncated: false },
+ skipped: { files: [], count: 0, truncated: false },
+ not_indexed: { dirs: [], dirs_count: 0, files: [], files_count: 0, truncated: false },
+};
+
+const METADATA = {
+ generation: 'g',
+ index_mode: 'full',
+ recorded_at: 'r',
+ recording_status: 'complete',
+ ignored_files_stored: 2,
+ ignored_files_total: 7,
+ hash_records_complete: true,
+ coverage_version: 2,
+ generation_matches: true,
+};
+
+/** Eine Scope-Seite bauen, so wie der Server sie schreibt. */
+function page(entries: { path: string; kind: string }[], hasMore: boolean, nextOffset?: number) {
+ return {
+ project: 'p',
+ metadata: METADATA,
+ paths: [],
+ scopes: [
+ {
+ requested_scope: '.',
+ scope: '.',
+ total: 42,
+ has_more: hasMore,
+ ...(nextOffset === undefined ? {} : { next_offset: nextOffset }),
+ entries: entries.map((entry) => ({ ...entry, detail: '' })),
+ status: 'known_gaps',
+ },
+ ],
+ };
+}
+
+/** Ein Client, der genau die vorbereiteten Antworten gibt und alles mitschreibt. */
+function fakeClient(pages: unknown[], status: unknown = STATUS) {
+ const calls: Record[] = [];
+ let index = 0;
+ const client = {
+ indexStatusPayload: vi.fn(async () => {
+ calls.push({ tool: 'index_status' });
+ return status;
+ }),
+ checkIndexCoverage: vi.fn(async (_project: string, args: Record) => {
+ calls.push({ tool: 'check_index_coverage', ...args });
+ const answer = pages[Math.min(index, pages.length - 1)];
+ index += 1;
+ return answer;
+ }),
+ } as unknown as RpcIntelligenceClient;
+ return { client, calls };
+}
+
+describe('loadCoverage', () => {
+
+ it('follows the real envelope cursor and resolves abbreviated status lists after all pages arrive', async () => {
+ const summary = { ...STATUS, indexed_at: 'g', parse_partial: { files: [], count: 1, truncated: true }, not_indexed: { dirs: [], dirs_count: 1, files: [], files_count: 1, truncated: true } };
+ const entries = [{ path: 'partial.c', kind: 'parse_partial' }, { path: 'build', kind: 'not_indexed_dir' }, { path: 'image.png', kind: 'not_indexed_file' }];
+ const envelope = (start: number, end: number) => ({
+ project: 'p', metadata: METADATA, has_more: end < entries.length,
+ ...(end < entries.length ? { next_offset: end } : {}),
+ scopes: [{ scope: '.', status: 'known_gaps', total: 3, returned: end - start, truncated: true, entries: entries.slice(start, end) }],
+ });
+ const { client, calls } = fakeClient([envelope(0, 2), envelope(2, 3)], summary);
+ const reading = await loadCoverage(client, 'p');
+ expect(calls.filter((call) => call.tool === 'check_index_coverage').map((call) => call.scopeOffset)).toEqual([0, 2]);
+ expect(reading.index.records.size).toBe(3);
+ expect(reading.index.records.get('partial.c')?.state).toBe('partial');
+ expect(reading.index.listingComplete).toBe(true);
+ expect(reading.index.truncations).toEqual([]);
+ });
+
+ it('rejects an index generation change across coverage pages or after the summary', async () => {
+ const first = page([{ path: 'a.c', kind: 'parse_partial' }], true, 1);
+ const second = { ...page([{ path: 'b.c', kind: 'parse_partial' }], false), metadata: { ...METADATA, generation: 'other' } };
+ await expect(loadCoverage(fakeClient([first, second]).client, 'p')).rejects.toThrow('Index generation changed');
+ await expect(loadCoverage(fakeClient([first], { ...STATUS, indexed_at: 'old' }).client, 'p')).rejects.toThrow('Index generation changed');
+ });
+
+ it('does not turn a missing root scope into a successful empty reading', async () => {
+ await expect(loadCoverage(fakeClient([{ scopes: [], metadata: METADATA }]).client, 'p')).rejects.toThrow('no root coverage scope');
+ });
+
+ it('fragt beide Quellen und legt sie zusammen', async () => {
+ const { client, calls } = fakeClient([page([{ path: 'src/b.ts', kind: 'discovery' }], false)]);
+ const reading = await loadCoverage(client, 'p');
+ expect(calls[0]).toEqual({ tool: 'index_status' });
+ expect(calls[1]).toMatchObject({
+ tool: 'check_index_coverage',
+ scopes: [COVERAGE_ROOT_SCOPE],
+ scopeLimit: COVERAGE_SCOPE_LIMIT,
+ scopeOffset: 0,
+ });
+ expect(reading.index.records.get('src/a.ts')?.state).toBe('partial');
+ expect(reading.index.records.get('src/b.ts')?.state).toBe('skipped');
+ });
+
+ it('reicht die Metadaten des Stores durch', async () => {
+ const { client } = fakeClient([page([], false)]);
+ const reading = await loadCoverage(client, 'p');
+ expect(reading.answer.metadata.ignoredFilesStored).toBe(2);
+ expect(reading.answer.metadata.ignoredFilesTotal).toBe(7);
+ });
+
+ it('holt weitere Seiten, solange der Server has_more sagt', async () => {
+ const { client, calls } = fakeClient([
+ page([{ path: 'a.ts', kind: 'discovery' }], true, 1),
+ page([{ path: 'b.ts', kind: 'discovery' }], true, 2),
+ page([{ path: 'c.ts', kind: 'discovery' }], false),
+ ]);
+ const reading = await loadCoverage(client, 'p');
+ const offsets = calls
+ .filter((call) => call['tool'] === 'check_index_coverage')
+ .map((call) => call['scopeOffset']);
+ expect(offsets).toEqual([0, 1, 2]);
+ expect(reading.scopes).toHaveLength(3);
+ expect([...reading.index.records.keys()]).toEqual(
+ expect.arrayContaining(['a.ts', 'b.ts', 'c.ts']),
+ );
+ });
+
+ it('haelt an, wenn der Server has_more sagt und den Zeiger nicht bewegt', async () => {
+ // Ohne diesen Abbruch waere das eine Endlosschleife im Browser.
+ const { client, calls } = fakeClient([page([{ path: 'a.ts', kind: 'discovery' }], true, 0)]);
+ const reading = await loadCoverage(client, 'p');
+ expect(calls.filter((call) => call['tool'] === 'check_index_coverage')).toHaveLength(1);
+ expect(reading.index.truncations.length).toBe(1);
+ });
+
+ it('hoert spaetestens am Seitendeckel auf und laesst die Kappung stehen', async () => {
+ const { client, calls } = fakeClient(
+ Array.from({ length: 40 }, (_unused, i) => page([{ path: `f${i}.ts`, kind: 'discovery' }], true, i + 1)),
+ );
+ const reading = await loadCoverage(client, 'p');
+ expect(calls.filter((call) => call['tool'] === 'check_index_coverage'))
+ .toHaveLength(COVERAGE_MAX_PAGES);
+ expect(reading.index.truncations.length).toBeGreaterThan(0);
+ });
+
+ it('bricht ab, statt einen leeren Befund zu liefern', async () => {
+ const client = {
+ indexStatusPayload: vi.fn(async () => STATUS),
+ checkIndexCoverage: vi.fn(async () => {
+ throw new Error('boom');
+ }),
+ } as unknown as RpcIntelligenceClient;
+ await expect(loadCoverage(client, 'p')).rejects.toThrow('boom');
+ });
+});
+
+describe('loadPathCoverage', () => {
+
+ it('fragt genau einen Pfad und liefert die Frische', async () => {
+ const calls: Record[] = [];
+ const client = {
+ checkIndexCoverage: vi.fn(async (_project: string, args: Record) => {
+ calls.push(args);
+ return {
+ project: 'p',
+ metadata: METADATA,
+ paths: [
+ {
+ requested_path: 'src/a.ts',
+ path: 'src/a.ts',
+ status: 'partial',
+ freshness: 'metadata_changed',
+ recommended_action: 'read_source_and_reindex',
+ coverage: [{ path: 'src/a.ts', kind: 'parse_partial', detail: '4-9', match: 'exact' }],
+ },
+ ],
+ scopes: [],
+ };
+ }),
+ } as unknown as RpcIntelligenceClient;
+ const answer = await loadPathCoverage(client, 'p', 'src/a.ts');
+ expect(calls[0]).toEqual({ paths: ['src/a.ts'] });
+ expect(answer?.freshness).toBe('metadata_changed');
+ expect(answer?.coverage[0]?.match).toBe('exact');
+ });
+
+ it('liefert undefined, wenn der Server zu dem Pfad nichts sagt', async () => {
+ const client = {
+ checkIndexCoverage: vi.fn(async () => ({ paths: [], scopes: [] })),
+ } as unknown as RpcIntelligenceClient;
+ expect(await loadPathCoverage(client, 'p', 'x.ts')).toBeUndefined();
+ });
+});
diff --git a/graph-ui/src/app/coverage-source.ts b/graph-ui/src/app/coverage-source.ts
new file mode 100644
index 000000000..320f6c257
--- /dev/null
+++ b/graph-ui/src/app/coverage-source.ts
@@ -0,0 +1,118 @@
+/**
+ * Die beiden Coverage-Quellen holen und zu einem Befund zusammenlegen.
+ *
+ * Der Join selbst steht in src/app/tree-model.ts und kennt keinen Server; hier
+ * steht, wer gefragt wird und wie oft. Drei Entscheidungen, die man sonst raten
+ * muesste:
+ *
+ * 1. **Beide Quellen, nicht eine.** `index_status` kappt seine Listen bei 500
+ * Eintraegen je Klasse (mcp.c, COVERAGE_FILE_CAP) und sagt es ueber
+ * `truncated`. `check_index_coverage` mit `scopes: ["."]` liest dieselbe
+ * Tabelle direkt und seitenweise. Wer nur die erste fragt, verliert bei
+ * einem grossen Projekt genau die Eintraege, wegen derer man fragt; wer nur
+ * die zweite fragt, verliert die Klassenzahlen und den Kappungs-Hinweis.
+ * 2. **Paginiert wird bis `has_more` false ist, mit einem Deckel.** Der Deckel
+ * ist kein Misstrauen gegen den Server, sondern gegen eine Antwort, die
+ * `has_more` sagt und `next_offset` nicht bewegt: ohne ihn waere das eine
+ * Endlosschleife im Browser. Wird er erreicht, bleibt `has_more` stehen und
+ * der Join schreibt daraus eine ehrliche Zeile.
+ * 3. **Ein Fehler ist kein leerer Befund.** Scheitert eine der beiden Fragen,
+ * wirft diese Datei. Ein stiller Rueckfall auf "keine Luecken" waere die
+ * schlimmste Luege, die dieser Zyklus bauen koennte: ein Baum, der behauptet,
+ * alles sei indiziert, weil er nicht nachfragen konnte.
+ */
+
+import type { RpcIntelligenceClient } from '../provider/rpc-client';
+import {
+ buildCoverageIndex,
+ readCoverageAnswer,
+ readIndexStatusCoverage,
+} from './tree-model';
+import type { CoverageAnswer, CoverageIndex, CoveragePathAnswer, CoverageScope } from './tree-model';
+
+/** Die Wurzel, wie `check_index_coverage` sie schreibt. */
+export const COVERAGE_ROOT_SCOPE = '.';
+
+/** Wie viele Eintraege je Seite geholt werden. Der Server deckelt bei 1000. */
+export const COVERAGE_SCOPE_LIMIT = 1000;
+
+/** Wie viele Seiten hoechstens geholt werden. Siehe Entscheidung 2 im Kopf. */
+export const COVERAGE_MAX_PAGES = 20;
+
+/** Was ein Coverage-Lauf zurueckbringt: der Join und die Metadaten dahinter. */
+export interface CoverageReading {
+ index: CoverageIndex;
+ /** Die Scope-Seiten, wie sie ankamen. Der Beweislauf schreibt sie mit. */
+ scopes: CoverageScope[];
+ /** Die Metadaten der letzten Antwort, inklusive der ignored_files-Zahlen. */
+ answer: CoverageAnswer;
+}
+
+/**
+ * Beide Quellen fragen und den Befund bauen.
+ *
+ * `index_status` zuerst, weil es die Klassenzahlen traegt und die Scope-Antwort
+ * sie nicht kennt.
+ */
+export async function loadCoverage(
+ client: RpcIntelligenceClient,
+ project: string,
+): Promise {
+ const status = readIndexStatusCoverage(await client.indexStatusPayload(project));
+
+ const scopes: CoverageScope[] = [];
+ let answer = readCoverageAnswer(undefined);
+ let generation: string | undefined;
+ let offset = 0;
+ for (let page = 0; page < COVERAGE_MAX_PAGES; page += 1) {
+ answer = readCoverageAnswer(
+ await client.checkIndexCoverage(project, {
+ scopes: [COVERAGE_ROOT_SCOPE],
+ scopeLimit: COVERAGE_SCOPE_LIMIT,
+ scopeOffset: offset,
+ }),
+ );
+ if ((status.generation && answer.metadata.generation && status.generation !== answer.metadata.generation)
+ || (generation !== undefined && generation !== answer.metadata.generation)) {
+ throw new Error('Index generation changed while reading coverage. Run the local diagnosis again.');
+ }
+ generation = answer.metadata.generation;
+ const scope = answer.scopes[0];
+ if (scope === undefined) {
+ throw new Error('The daemon returned no root coverage scope. Coverage completeness is unknown.');
+ }
+ scopes.push(scope);
+ if (!scope.hasMore) {
+ break;
+ }
+ const next = scope.nextOffset ?? offset + scope.entries.length;
+ if (next <= offset) {
+ // Der Server sagt "mehr", bewegt den Zeiger aber nicht. Hier wird
+ // abgebrochen und die Kappung bleibt im Befund stehen.
+ break;
+ }
+ offset = next;
+ }
+
+ return { index: buildCoverageIndex({ status, scopes }), scopes, answer };
+}
+
+/**
+ * Was der Store ueber genau einen Pfad sagt, mit Frische.
+ *
+ * Eine eigene Frage und nicht ein Blick in den Scope-Befund: die Frische
+ * (`metadata_match` gegen `metadata_changed`) wird beim Fragen aus mtime und
+ * Groesse der Datei berechnet (mcp.c, coverage_path_freshness), steht also in
+ * keiner Liste, die vorher geholt wurde. Genau darum meldet sie eine Datei, die
+ * sich seit dem Index geaendert hat.
+ */
+export async function loadPathCoverage(
+ client: RpcIntelligenceClient,
+ project: string,
+ path: string,
+): Promise {
+ const answer = readCoverageAnswer(
+ await client.checkIndexCoverage(project, { paths: [path] }),
+ );
+ return answer.paths[0];
+}
diff --git a/graph-ui/src/app/coverage-strings.test.ts b/graph-ui/src/app/coverage-strings.test.ts
new file mode 100644
index 000000000..5cb2658f4
--- /dev/null
+++ b/graph-ui/src/app/coverage-strings.test.ts
@@ -0,0 +1,135 @@
+/*
+ * Die Coverage-Saetze: geprueft wird, was sie NICHT sagen.
+ *
+ * Ein Satz, der einen Grund erfindet, faellt in einem Beweisbild nicht auf; er
+ * liest sich sogar besser. Darum steht hier zu jedem Satz ein Fall ohne Grund
+ * daneben, und der Test verlangt, dass die Formulierung dann ohne Grund endet.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+ COVERAGE_DESCRIPTIONS,
+ COVERAGE_LABELS,
+ COVERAGE_MARKS,
+ COVERAGE_SOURCE_NOTE,
+ coverageTooltip,
+ folderTooltip,
+ freshnessNote,
+ freshnessNoteNeeded,
+ partialFileNote,
+ readerUnavailableNote,
+} from './coverage-strings';
+import { COVERAGE_ORDER } from './tree-model';
+
+describe('die Tabellen decken jede Stufe ab', () => {
+
+ it('hat zu jeder Stufe ein Wort, ein Zeichen und einen Satz', () => {
+ for (const state of COVERAGE_ORDER) {
+ expect(COVERAGE_LABELS[state].length).toBeGreaterThan(0);
+ expect(COVERAGE_DESCRIPTIONS[state].length).toBeGreaterThan(0);
+ expect(typeof COVERAGE_MARKS[state]).toBe('string');
+ }
+ });
+
+ it('laesst genau die vollstaendige Stufe ohne Zeichen', () => {
+ expect(COVERAGE_MARKS.indexed).toBe('');
+ expect(COVERAGE_MARKS.partial).toBe('!');
+ });
+});
+
+describe('COVERAGE_SOURCE_NOTE', () => {
+
+ it('benennt die Grenze des Baums und nicht seinen Inhalt', () => {
+ expect(COVERAGE_SOURCE_NOTE).toContain('as the index discovery saw them');
+ expect(COVERAGE_SOURCE_NOTE).toContain('never met are invisible');
+ });
+});
+
+describe('coverageTooltip', () => {
+
+ it('nennt bei partial Zeilenbereiche und keine Symbolzahl', () => {
+ const text = coverageTooltip('partial', '12-18,24');
+ expect(text).toContain('partially parsed');
+ expect(text).toContain('12-18,24');
+ // Der Server meldet Bereiche, keine Zahl. Eine Zahl waere gezaehlt,
+ // und gezaehlt hat sie niemand.
+ expect(text).not.toMatch(/\d+ symbols/);
+ });
+
+ it('endet ohne Grund, wenn der Server keinen genannt hat', () => {
+ expect(coverageTooltip('skipped')).toContain('named no reason');
+ expect(coverageTooltip('not-indexed')).toContain('named no rule');
+ });
+
+ it('sagt bei einem Grund den Grund des Servers', () => {
+ expect(coverageTooltip('skipped', 'unsupported extension'))
+ .toContain('unsupported extension');
+ expect(coverageTooltip('not-indexed', '.gitignore')).toContain('.gitignore');
+ });
+
+ it('behauptet fuer indexed keine Vollstaendigkeit, nur Befundfreiheit', () => {
+ expect(coverageTooltip('indexed')).toContain('no source recorded an issue');
+ });
+});
+
+describe('folderTooltip', () => {
+
+ it('spricht ueber den Inhalt und nicht ueber den Ordner selbst', () => {
+ expect(folderTooltip('skipped')).toContain('worst stage below this folder');
+ expect(folderTooltip('indexed')).toContain('every file below it');
+ });
+});
+
+describe('readerUnavailableNote', () => {
+
+ const note = readerUnavailableNote('assets/beleg.png', 'skipped', 'unsupported extension');
+
+ it('erklaert die Grenze der Server-Flaeche statt einen Fehler zu melden', () => {
+ expect(note).toContain('get_code_snippet');
+ expect(note).toContain('Upstream-Ask 1');
+ expect(note).not.toMatch(/error|failed/i);
+ });
+
+ it('sagt ausdruecklich, dass die Datei existiert', () => {
+ expect(note).toContain('is on disk');
+ });
+
+ it('nennt den Grund des Servers, wenn es einen gibt, und sonst keinen', () => {
+ expect(note).toContain('unsupported extension');
+ expect(readerUnavailableNote('a.bin', 'not-indexed')).toContain('named no rule');
+ });
+});
+
+describe('partialFileNote', () => {
+
+ it('qualifiziert den Text, statt ihn zu verdaechtigen', () => {
+ const text = partialFileNote('src/broken.ts', '12-18');
+ expect(text).toContain('12-18');
+ expect(text).toContain('The text below is the file');
+ });
+});
+
+describe('freshnessNoteNeeded', () => {
+
+ it('schweigt genau dann, wenn der Server nichts zu melden hat', () => {
+ expect(freshnessNoteNeeded('no_recorded_issue', 'metadata_match')).toBe(false);
+ });
+
+ it('meldet eine geaenderte Datei und einen aufgezeichneten Befund', () => {
+ expect(freshnessNoteNeeded('no_recorded_issue', 'metadata_changed')).toBe(true);
+ expect(freshnessNoteNeeded('partial', 'metadata_match')).toBe(true);
+ expect(freshnessNoteNeeded('coverage_unavailable', 'unavailable')).toBe(true);
+ });
+});
+
+describe('freshnessNote', () => {
+
+ it('nennt Frische, Befund und Empfehlung in dieser Reihenfolge', () => {
+ expect(freshnessNote('partial', 'metadata_changed', 'read_source_and_reindex'))
+ .toBe('metadata_changed / partial / read_source_and_reindex');
+ });
+
+ it('laesst weg, was der Server nicht geschrieben hat', () => {
+ expect(freshnessNote('', 'metadata_changed', '')).toBe('metadata_changed');
+ expect(freshnessNote('', '', '')).toBe('unknown');
+ });
+});
diff --git a/graph-ui/src/app/coverage-strings.ts b/graph-ui/src/app/coverage-strings.ts
new file mode 100644
index 000000000..fe874850c
--- /dev/null
+++ b/graph-ui/src/app/coverage-strings.ts
@@ -0,0 +1,170 @@
+/**
+ * Die Saetze, mit denen der Explorer und der Reader ueber Coverage sprechen.
+ *
+ * An einer Stelle, weil dieselbe Aussage an drei Orten auftaucht: als Tooltip
+ * an der Baumzeile, als Legende im Explorer-Fuss und als Erklaerung ueber dem
+ * Editor. Drei Formulierungen fuer denselben Befund waeren drei Gelegenheiten,
+ * eine davon zu schaerfen, bis sie mehr behauptet als der Server gesagt hat.
+ *
+ * Zwei Regeln gelten fuer jeden Satz hier:
+ *
+ * 1. **Kein Grund wird erfunden.** Nennt der Server keinen, endet der Satz
+ * ohne Grund, statt einen plausiblen anzuhaengen.
+ * 2. **Abwesenheit wird als Abwesenheit benannt.** "nicht indiziert" heisst
+ * nicht "leer" und nicht "kaputt", sondern dass diese Oberflaeche ueber den
+ * Inhalt nichts weiss.
+ */
+
+import type { CoverageState } from './tree-model';
+
+/**
+ * Der ehrliche Satz ueber die Grenze des Baums.
+ *
+ * Er steht im Explorer-Fuss, weil genau dort die Frage entsteht: der Baum sieht
+ * aus wie ein Dateisystem, ist aber die Vereinigung dessen, was die Discovery
+ * des Indexers gesehen hat. Eine Datei, die sie nie erreicht hat, steht in
+ * keiner der drei Quellen und kann darum auch hier nicht stehen.
+ */
+export const COVERAGE_SOURCE_NOTE =
+ 'files as the index discovery saw them; files it never met are invisible until the server lists directories';
+
+/** Das kurze Wort einer Stufe, so wie es in der Legende steht. */
+export const COVERAGE_LABELS: Record = {
+ indexed: 'indexed',
+ partial: 'partially parsed',
+ skipped: 'skipped',
+ 'not-indexed': 'not indexed',
+ ignored: 'ignored',
+};
+
+/**
+ * Das Zeichen, das eine Datei-Zeile traegt.
+ *
+ * Ordner tragen stattdessen einen Punkt, denn ihr Marker ist eine Aussage ueber
+ * ihren Inhalt und nicht ueber sie selbst.
+ */
+export const COVERAGE_MARKS: Record = {
+ indexed: '',
+ partial: '!',
+ skipped: 'x',
+ 'not-indexed': '-',
+ ignored: '-',
+};
+
+/** Der Punkt, den ein Ordner traegt, wenn unter ihm etwas fehlt. */
+export const COVERAGE_FOLDER_MARK = '●';
+
+/** Was die Legende zu einer Stufe sagt. */
+export const COVERAGE_DESCRIPTIONS: Record = {
+ indexed: 'in the graph, no recorded issue',
+ partial: 'indexed, but constructs in the listed line ranges may be missing',
+ skipped: 'the indexer gave up on it, with its own reason',
+ 'not-indexed': 'excluded by design (gitignore/.cbmignore), with the rule as the reason',
+ ignored: 'the coverage store lists it as ignored',
+};
+
+/**
+ * Der Tooltip einer Zeile.
+ *
+ * Der Grund wird angehaengt, wenn der Server einen genannt hat, und sonst
+ * nicht. Der Wortlaut fuer `partial` nennt Zeilenbereiche statt einer Zahl von
+ * Symbolen: der Server meldet Bereiche und keine Zahl, und "n symbols may be
+ * missing" waere eine Zahl, die niemand gezaehlt hat.
+ */
+export function coverageTooltip(state: CoverageState, reason = ''): string {
+ const trimmed = reason.trim();
+ if (state === 'indexed') {
+ return 'indexed: the graph carries this file and no source recorded an issue';
+ }
+ if (state === 'partial') {
+ return trimmed.length > 0
+ ? `partially parsed: constructs inside lines ${trimmed} may be missing from the graph`
+ : 'partially parsed: constructs inside the flagged ranges may be missing from the graph';
+ }
+ if (state === 'skipped') {
+ return trimmed.length > 0
+ ? `skipped by the indexer: ${trimmed}. Nothing of it is in the graph.`
+ : 'skipped by the indexer, which named no reason. Nothing of it is in the graph.';
+ }
+ if (state === 'ignored') {
+ return trimmed.length > 0
+ ? `ignored by the coverage store: ${trimmed}`
+ : 'ignored by the coverage store, which named no reason';
+ }
+ return trimmed.length > 0
+ ? `not indexed by design: ${trimmed}. Change the ignore rules and re-index to include it.`
+ : 'not indexed by design, and the server named no rule. Nothing of it is in the graph.';
+}
+
+/** Der Tooltip eines Ordners: was seine schlechteste Stufe bedeutet. */
+export function folderTooltip(worst: CoverageState): string {
+ if (worst === 'indexed') {
+ return 'every file below it is in the graph without a recorded issue';
+ }
+ return `worst stage below this folder: ${COVERAGE_LABELS[worst]} (${COVERAGE_DESCRIPTIONS[worst]})`;
+}
+
+/**
+ * Was der Reader statt eines Fehlers sagt, wenn eine Datei keinen Inhalt hat.
+ *
+ * Kein "failed to load": nichts ist schiefgegangen. Der Server liefert Inhalt
+ * ausschliesslich ueber `get_code_snippet` auf einem indizierten Symbol
+ * (src/reader/file-source.ts), also hat eine Datei ohne Modul-Knoten hier
+ * keinen Inhalt, bis Ask 1 erfuellt ist. Das ist eine Aussage ueber die
+ * heutige Server-Flaeche und nicht ueber die Datei.
+ */
+export function readerUnavailableNote(path: string, state: CoverageState, reason = ''): string {
+ const trimmed = reason.trim();
+ const why =
+ state === 'skipped'
+ ? trimmed.length > 0
+ ? `the indexer skipped it (${trimmed})`
+ : 'the indexer skipped it and named no reason'
+ : state === 'ignored'
+ ? trimmed.length > 0
+ ? `the coverage store lists it as ignored (${trimmed})`
+ : 'the coverage store lists it as ignored'
+ : trimmed.length > 0
+ ? `it is excluded by design (${trimmed})`
+ : 'it is excluded by design and the server named no rule';
+ return (
+ `${path} has no content here: ${why}, so the index holds no module node for it. `
+ + 'This server delivers file content only through get_code_snippet on an indexed symbol, '
+ + 'so there is nothing to read until a file endpoint exists (Upstream-Ask 1). '
+ + 'The file itself is on disk; this surface simply cannot show it.'
+ );
+}
+
+/** Die Notiz ueber dem Editor, wenn nur Teile der Datei im Graphen stehen. */
+export function partialFileNote(path: string, reason = ''): string {
+ const trimmed = reason.trim();
+ return trimmed.length > 0
+ ? `${path} is only partially parsed: constructs inside lines ${trimmed} may be missing from the graph. `
+ + 'The text below is the file; the graph around it is incomplete.'
+ : `${path} is only partially parsed: constructs inside the flagged ranges may be missing from the graph. `
+ + 'The text below is the file; the graph around it is incomplete.';
+}
+
+/**
+ * Die Frische-Notiz der Statusleiste.
+ *
+ * Sie steht nur da, wenn sie etwas zu sagen hat: `metadata_match` zusammen mit
+ * `no_recorded_issue` heisst, dass die Datei seit dem Index unveraendert ist
+ * und keine Quelle etwas ueber sie gemeldet hat. Diesen Fall auch anzuzeigen
+ * hiesse, eine gruene Ampel zu bauen, die immer leuchtet.
+ */
+export function freshnessNoteNeeded(status: string, freshness: string): boolean {
+ return !(freshness === 'metadata_match' && status === 'no_recorded_issue');
+}
+
+/** Was in der Statusleiste steht, wenn eine Frische-Notiz noetig ist. */
+export function freshnessNote(status: string, freshness: string, action: string): string {
+ const parts = [freshness.length > 0 ? freshness : 'unknown'];
+ if (status.length > 0) {
+ parts.push(status);
+ }
+ if (action.length > 0) {
+ parts.push(action);
+ }
+ return parts.join(' / ');
+}
diff --git a/graph-ui/src/app/feature-flags.test.ts b/graph-ui/src/app/feature-flags.test.ts
new file mode 100644
index 000000000..edf37e612
--- /dev/null
+++ b/graph-ui/src/app/feature-flags.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, expect, it, vi } from 'vitest';
+
+afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); });
+
+it.each([undefined, '', 'false', '1', 'TRUE'])('keeps Agents unavailable for build value %s', async value => {
+ vi.stubEnv('VITE_EXPERIMENTAL_AGENTS', value);
+ vi.resetModules();
+ const { experimentalAgentsEnabled } = await import('./feature-flags');
+ const { initialWorkspace, allowedWorkspace, availableWorkspaces } = await import('./workspace-strings');
+ const { lineCommandOf } = await import('../layout/layout-command');
+ const { WIRED_MENU_SHORTCUTS } = await import('./shortcuts');
+ expect(experimentalAgentsEnabled).toBe(false);
+ expect(availableWorkspaces().map(item => item.id)).not.toContain('agents');
+ expect(initialWorkspace('agents', 'system')).toBe('explore');
+ expect(initialWorkspace(null, 'agents')).toBe('explore');
+ expect(allowedWorkspace('agents')).toBe('explore');
+ expect(initialWorkspace('coverage', 'agents')).toBe('coverage');
+ expect(initialWorkspace('adr', null)).toBe('adr');
+ expect(initialWorkspace(null, 'adr')).toBe('adr');
+ expect(initialWorkspace(null, null)).toBe('architecture');
+ expect(lineCommandOf('live agents')).toBe('none');
+ expect(lineCommandOf('fullscreen')).toBe('none');
+ expect(WIRED_MENU_SHORTCUTS).not.toContain('g');
+ expect(WIRED_MENU_SHORTCUTS).toContain('l');
+});
+
+it('restores the experimental workspace and controls only with explicit build opt-in', async () => {
+ vi.stubEnv('VITE_EXPERIMENTAL_AGENTS', 'true');
+ vi.resetModules();
+ const { experimentalAgentsEnabled } = await import('./feature-flags');
+ const { initialWorkspace, allowedWorkspace, availableWorkspaces } = await import('./workspace-strings');
+ const { lineCommandOf } = await import('../layout/layout-command');
+ const { WIRED_MENU_SHORTCUTS } = await import('./shortcuts');
+ expect(experimentalAgentsEnabled).toBe(true);
+ expect(availableWorkspaces().map(item => item.id)).toContain('agents');
+ expect(initialWorkspace('agents', null)).toBe('agents');
+ expect(initialWorkspace(null, 'agents')).toBe('agents');
+ expect(allowedWorkspace('agents')).toBe('agents');
+ expect(lineCommandOf('live agents')).toBe('toggle-live-agents');
+ expect(lineCommandOf('fullscreen')).toBe('toggle-fullscreen');
+ expect(WIRED_MENU_SHORTCUTS).toContain('g');
+});
diff --git a/graph-ui/src/app/feature-flags.ts b/graph-ui/src/app/feature-flags.ts
new file mode 100644
index 000000000..a5f4c11ce
--- /dev/null
+++ b/graph-ui/src/app/feature-flags.ts
@@ -0,0 +1,2 @@
+/** Build-time opt-in; URL parameters and saved preferences cannot enable it. */
+export const experimentalAgentsEnabled = import.meta.env.VITE_EXPERIMENTAL_AGENTS === 'true';
diff --git a/graph-ui/src/app/key-probe.test.ts b/graph-ui/src/app/key-probe.test.ts
new file mode 100644
index 000000000..67144a046
--- /dev/null
+++ b/graph-ui/src/app/key-probe.test.ts
@@ -0,0 +1,104 @@
+/**
+ * Der Tastentest, ohne Fenster.
+ *
+ * Geprueft wird die eine Frage, fuer die er gebaut ist und die der Nutzerbefund
+ * vom 2026-08-29 stellt: WER hat den Tastendruck verbraucht. `defaultPrevented`
+ * allein beantwortet sie nicht, weil der Griff dieser Oberflaeche ein erkanntes
+ * Kuerzel selbst abbestellt; ein Test, der die beiden Faelle nicht trennt, wuerde
+ * bei jedem funktionierenden Kuerzel "jemand anderes war es" melden und damit
+ * genau in dem Fall irrefuehren, fuer den er da ist.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { handledShortcutOf, markHandled, NO_KEY_READING, readKeyEvent } from './key-probe';
+import { WIRED_MENU_SHORTCUTS } from './shortcuts';
+
+const BODY = { tagName: 'BODY' };
+const INPUT = { tagName: 'INPUT' };
+
+const alt = (letter: string) => ({
+ key: letter,
+ code: `Key${letter.toUpperCase()}`,
+ altKey: true,
+});
+
+describe('readKeyEvent', () => {
+
+ it('schreibt die physische Taste UND das Zeichen auf, weil sie sich unterscheiden', () => {
+ // Option+A macht unter macOS ein Ring-A. Genau deshalb liest die
+ // Verdrahtung `code`, und genau deshalb muss der Test beides zeigen.
+ const reading = readKeyEvent(
+ { key: 'å', code: 'KeyA', altKey: true },
+ BODY,
+ WIRED_MENU_SHORTCUTS,
+ );
+ expect(reading.code).toBe('KeyA');
+ expect(reading.key).toBe('å');
+ expect(reading.altKey).toBe(true);
+ expect(reading.shortcut).toBe('a');
+ });
+
+ it('nennt kein Kuerzel, wo keines ist', () => {
+ // `z` ist an keinen Menuepunkt vergeben. `g` war es bis W11a und ist
+ // seitdem der Live-Modus der Agenten; ein Test, der die Abwesenheit an
+ // einem vergebenen Buchstaben prueft, prueft irgendwann das Gegenteil.
+ expect(readKeyEvent(alt('z'), BODY, WIRED_MENU_SHORTCUTS).shortcut).toBe('');
+ expect(readKeyEvent({ key: 'a', code: 'KeyA' }, BODY, WIRED_MENU_SHORTCUTS).shortcut).toBe('');
+ });
+
+ it('sagt, dass ein Kuerzel auch im Eingabefeld gegolten haette', () => {
+ const reading = readKeyEvent(alt('w'), INPUT, WIRED_MENU_SHORTCUTS);
+ expect(reading.shortcut).toBe('w');
+ expect(reading.typingTarget).toBe(true);
+ expect(reading.targetTag).toBe('INPUT');
+ });
+
+ it('trennt "wir haben es genommen" von "jemand vor uns hat es genommen"', () => {
+ const mine: Record = { ...alt('w'), defaultPrevented: true };
+ markHandled(mine, 'w');
+ expect(readKeyEvent(mine as never, BODY, WIRED_MENU_SHORTCUTS).consumedBy).toBe('this-window');
+
+ const theirs = { ...alt('w'), defaultPrevented: true };
+ expect(readKeyEvent(theirs, BODY, WIRED_MENU_SHORTCUTS).consumedBy).toBe('something-else');
+
+ expect(readKeyEvent(alt('w'), BODY, WIRED_MENU_SHORTCUTS).consumedBy).toBe('nobody');
+ });
+
+ it('beantwortet die Frage "war das ein Kuerzel" auch fuer ein verbrauchtes Ereignis', () => {
+ // Sonst waere der Test bei genau dem Befund stumm, den er klaeren soll:
+ // "die Taste tut nichts" plus "sie war schon verbraucht" ist die
+ // Auskunft, ohne die niemand weiterkommt.
+ const eaten = { ...alt('b'), defaultPrevented: true };
+ expect(readKeyEvent(eaten, BODY, WIRED_MENU_SHORTCUTS).shortcut).toBe('b');
+ });
+
+ it('wirft nicht an einem Ereignis ohne Zeichen', () => {
+ // Bruecken (Erweiterungen, Automatisierung) schicken solche Ereignisse.
+ // Ein Test, der daran stirbt, waere selbst die Stoerung.
+ const reading = readKeyEvent(
+ { code: 'KeyW', altKey: true } as never,
+ BODY,
+ WIRED_MENU_SHORTCUTS,
+ );
+ expect(reading.key).toBe('');
+ expect(reading.shortcut).toBe('w');
+ });
+
+ it('faengt mit einer leeren Ablesung an, statt eine Taste zu erfinden', () => {
+ expect(NO_KEY_READING.code).toBe('');
+ expect(NO_KEY_READING.shortcut).toBe('');
+ expect(NO_KEY_READING.consumedBy).toBe('nobody');
+ });
+});
+
+describe('markHandled', () => {
+
+ it('zeichnet genau das Ereignis, das verbraucht wurde, und kein zweites', () => {
+ const first: Record = { key: 'w' };
+ const second: Record = { key: 'w' };
+ markHandled(first, 'w');
+ expect(handledShortcutOf(first)).toBe('w');
+ expect(handledShortcutOf(second)).toBeUndefined();
+ });
+});
diff --git a/graph-ui/src/app/key-probe.ts b/graph-ui/src/app/key-probe.ts
new file mode 100644
index 000000000..658df8a77
--- /dev/null
+++ b/graph-ui/src/app/key-probe.ts
@@ -0,0 +1,155 @@
+/**
+ * Der Tastentest: was von einem Tastendruck wirklich ankommt, und was diese
+ * Oberflaeche daraus gemacht haette.
+ *
+ * ## Warum es diese Datei gibt
+ *
+ * Nutzerbefund vom 2026-08-29: "alt plus letter funktioniert nur fuer atlas".
+ * An der laufenden Vorschau war er nicht nachzustellen: Alt+a, Alt+w, Alt+b,
+ * Alt+c und Alt+l loesen in Chromium alle aus, bei Fokus auf dem Koerper wie im
+ * Editor, und der Tastendruck kommt mit `code: "KeyW"` und `defaultPrevented:
+ * false` am Fenster an. Ein nicht reproduzierbarer Befund laesst genau zwei
+ * ehrliche Antworten zu: raten, oder messen lassen. Diese Datei ist die zweite.
+ *
+ * Sie beantwortet die vier Fragen, an denen ein Kuerzel scheitern kann, und sie
+ * beantwortet sie an dem Geraet, an dem es scheitert:
+ *
+ * 1. **Kommt der Tastendruck ueberhaupt an?** Steht nach dem Druck nichts im
+ * Test, hat ihn die Tastaturbelegung, das Betriebssystem oder eine
+ * Erweiterung vorher abgefangen; dann ist die Anwendung nicht beteiligt.
+ * 2. **Traegt er Alt/Option?** Manche Belegungen schicken Option als
+ * Zeichenmodifikator und setzen `altKey` nicht.
+ * 3. **Welche physische Taste war es?** Unter macOS macht Option+A ein `å`.
+ * Deshalb liest die Verdrahtung `code` und nicht `key`, und der Test zeigt
+ * beides nebeneinander, damit der Unterschied sichtbar ist.
+ * 4. **Hat ihn jemand vor uns verbraucht?** `defaultPrevented` allein sagt das
+ * nicht: der Griff dieser Oberflaeche bestellt ein erkanntes Kuerzel selbst
+ * ab. Der Test trennt die beiden Faelle, indem der Griff das Ereignis
+ * zeichnet, das er verbraucht hat.
+ *
+ * Alles hier ist eine reine Funktion ueber ein Ereignis. Kein DOM, kein React,
+ * keine Zeitmessung: der Test der Hilfeseite zeichnet nur, was hier
+ * herauskommt, und die Unit-Tests pruefen es ohne Fenster.
+ */
+
+import { isTypingTarget, menuShortcutFor } from './keyboard';
+import type { KeyEventLike, KeyTargetLike } from './keyboard';
+
+/**
+ * Die Marke, mit der der Griff am Fenster ein von IHM verbrauchtes Ereignis
+ * zeichnet.
+ *
+ * Am Ereignis selbst und nicht in einer Nebenbuchhaltung, weil die Frage
+ * "wurde GENAU DIESER Druck von uns verbraucht" sonst ueber Zeitstempel
+ * beantwortet werden muesste, und zwei Tastendruecke in derselben
+ * Millisekunde sind kein erfundener Fall.
+ */
+const HANDLED = '__atlasHandledShortcut';
+
+/** Zeichnet ein Ereignis als von dieser Oberflaeche verbraucht. */
+export function markHandled(event: object, shortcut: string): void {
+ (event as Record)[HANDLED] = shortcut;
+}
+
+/** Das Kuerzel, als das diese Oberflaeche das Ereignis verbraucht hat, oder nichts. */
+export function handledShortcutOf(event: object): string | undefined {
+ const value = (event as Record)[HANDLED];
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+/**
+ * Wer den Tastendruck verbraucht hat.
+ *
+ * Drei Werte und nicht zwei: "niemand" und "wir" sind beide der gute Fall,
+ * "jemand anderes" ist der einzige, der den Nutzerbefund erklaeren wuerde.
+ */
+export type KeyProbeConsumer = 'nobody' | 'this-window' | 'something-else';
+
+/** Was von einem Tastendruck ankam, und was daraus geworden waere. */
+export interface KeyProbeReading {
+ /** Die physische Taste, zum Beispiel `KeyW`. Leer, wenn der Browser keine nennt. */
+ code: string;
+ /** Das erzeugte Zeichen. Unter macOS mit Option oft ein anderes als der Buchstabe. */
+ key: string;
+ altKey: boolean;
+ ctrlKey: boolean;
+ metaKey: boolean;
+ shiftKey: boolean;
+ defaultPrevented: boolean;
+ consumedBy: KeyProbeConsumer;
+ /**
+ * Das Menuekuerzel, das die Anwendung erkannt haette. Leer heisst: keines.
+ *
+ * Gerechnet wird mit derselben Funktion, die der Griff am Fenster ruft, und
+ * ausdruecklich so, als haette niemand das Ereignis abbestellt: die Frage
+ * lautet "war das ein Kuerzel", nicht "war es noch zu haben".
+ */
+ shortcut: string;
+ /** Das Element, an dem der Druck ankam. Leer, wenn keines genannt wurde. */
+ targetTag: string;
+ /** Ob dort gerade getippt wird. Ein blankes Kuerzel gilt dann nicht. */
+ typingTarget: boolean;
+}
+
+/** Die leere Ablesung, solange noch nichts gedrueckt wurde. */
+export const NO_KEY_READING: KeyProbeReading = {
+ code: '',
+ key: '',
+ altKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ defaultPrevented: false,
+ consumedBy: 'nobody',
+ shortcut: '',
+ targetTag: '',
+ typingTarget: false,
+};
+
+/** Was von diesem Tastendruck ankam. */
+export function readKeyEvent(
+ event: KeyEventLike,
+ target: KeyTargetLike | null | undefined,
+ shortcuts: readonly string[],
+): KeyProbeReading {
+ const defaultPrevented = event.defaultPrevented === true;
+ /*
+ * Die Felder werden einzeln abgeschrieben und das Ereignis NICHT kopiert.
+ *
+ * `{ ...event }` liefert bei einem echten KeyboardEvent ein leeres Objekt:
+ * `code`, `key` und die Modifikatoren liegen auf dem Prototyp und nicht am
+ * Objekt selbst. Ein Test, der so kopiert, meldet zu jedem Tastendruck
+ * "kein Kuerzel", also genau den Befund, den er aufklaeren soll.
+ */
+ const arrived: KeyEventLike = {
+ key: event.key ?? '',
+ code: event.code ?? '',
+ altKey: event.altKey === true,
+ ctrlKey: event.ctrlKey === true,
+ metaKey: event.metaKey === true,
+ shiftKey: event.shiftKey === true,
+ // Ausdruecklich false: die Frage lautet "war das ein Kuerzel", nicht
+ // "war es noch zu haben".
+ defaultPrevented: false,
+ };
+ const handled = handledShortcutOf(event as unknown as object);
+ // Der Griff am Fenster bestellt ein erkanntes Kuerzel selbst ab. Ohne die
+ // Marke sagte `defaultPrevented` also bei jedem funktionierenden Kuerzel
+ // "jemand hat es verbraucht", und der Test waere in genau dem Fall
+ // irrefuehrend, fuer den er gebaut ist.
+ const consumedBy: KeyProbeConsumer =
+ handled !== undefined ? 'this-window' : defaultPrevented ? 'something-else' : 'nobody';
+ return {
+ code: arrived.code ?? '',
+ key: arrived.key,
+ altKey: arrived.altKey === true,
+ ctrlKey: arrived.ctrlKey === true,
+ metaKey: arrived.metaKey === true,
+ shiftKey: arrived.shiftKey === true,
+ defaultPrevented,
+ consumedBy,
+ shortcut: menuShortcutFor(arrived, target, shortcuts) ?? '',
+ targetTag: (target?.tagName ?? '').toUpperCase(),
+ typingTarget: isTypingTarget(target),
+ };
+}
diff --git a/graph-ui/src/app/keyboard.test.ts b/graph-ui/src/app/keyboard.test.ts
new file mode 100644
index 000000000..c27f44771
--- /dev/null
+++ b/graph-ui/src/app/keyboard.test.ts
@@ -0,0 +1,199 @@
+/*
+ * Wer eine Taste bekommt.
+ *
+ * Der Befund, gegen den diese Datei geschnitten ist, stammt vom 2026-08-29 und
+ * ist an einem gruenen Bildschirm nicht zu sehen: nach dem Laden liegt der Fokus
+ * auf BODY, und wer "create" tippt, sah bis dahin nichts in der Kommandozeile
+ * ankommen, waehrend das "c" im Hintergrund die Aenderungsansicht aufschlug. Die
+ * Regeln dagegen stehen in keyboard.ts; hier stehen sie als Faelle.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { llmMenuLabel } from '../llm/strings';
+import { BUG_WIZARD_MENU_LABEL } from '../traces/bug-wizard-strings';
+import { WHY_MENU_LABEL } from '../why/why-model';
+import {
+ EDITOR_SURFACE,
+ FOCUS_COMMAND_KEY,
+ RESERVED_BARE_SHORTCUTS,
+ commandLineIntent,
+ isTypingTarget,
+ menuShortcutFor,
+} from './keyboard';
+import { WIRED_MENU_SHORTCUTS } from './shortcuts';
+
+const BODY = { tagName: 'BODY' };
+const alt = (letter: string) => ({ key: letter, code: `Key${letter.toUpperCase()}`, altKey: true });
+
+describe('isTypingTarget', () => {
+
+ it('erkennt die Felder, in denen getippt wird', () => {
+ expect(isTypingTarget({ tagName: 'INPUT' })).toBe(true);
+ expect(isTypingTarget({ tagName: 'textarea' })).toBe(true);
+ expect(isTypingTarget({ tagName: 'SELECT' })).toBe(true);
+ expect(isTypingTarget({ tagName: 'DIV', isContentEditable: true })).toBe(true);
+ });
+
+ /*
+ * Der Editor, und zwar an seiner Flaeche und nicht an seinem inneren
+ * Element.
+ *
+ * Der Kopf von keyboard.ts nahm bis W8 an, Monaco haenge seine Tastatur an
+ * ein verstecktes `textarea`, und damit sei der Fall von der Zeile darueber
+ * schon erledigt. Seit Monaco 0.56 ist es ein `div` mit der
+ * EditContext-API; die Annahme war still falsch geworden, und ein
+ * Tastendruck im Editor landete in der Kommandozeile. Gefunden hat es der
+ * Beweislauf von W2a (die Datei im Reader wechselte beim Tippen), und
+ * gemessen wurde es am Stand VOR W8: der Befund ist aelter als der Umbau.
+ */
+ it('erkennt die Flaeche des Editors, egal welches Element darin das Ziel ist', () => {
+ const inEditor = {
+ tagName: 'DIV',
+ closest: (selector: string) => (selector === EDITOR_SURFACE ? {} : null),
+ };
+ expect(isTypingTarget(inEditor)).toBe(true);
+ expect(commandLineIntent({ key: 'a' }, inEditor)).toBeUndefined();
+ });
+
+ it('haelt alles andere fuer eine Flaeche, an der ein Kuerzel gilt', () => {
+ const outside = { tagName: 'BODY', closest: () => null };
+ expect(isTypingTarget(outside)).toBe(false);
+ expect(isTypingTarget({ tagName: 'BODY' })).toBe(false);
+ expect(isTypingTarget({ tagName: 'UL' })).toBe(false);
+ expect(isTypingTarget(null)).toBe(false);
+ expect(isTypingTarget(undefined)).toBe(false);
+ });
+});
+
+describe('menuShortcutFor', () => {
+
+ it('gibt den Buchstaben, wenn Alt/Option dabei ist', () => {
+ expect(menuShortcutFor(alt('a'), BODY, WIRED_MENU_SHORTCUTS)).toBe('a');
+ });
+
+ /*
+ * Der Grund fuer `event.code`: unter macOS erzeugt Option+A ein `å`. Ein
+ * Kuerzel, das auf `event.key` schaut, waere dort taub, und der Fehler
+ * zeigte sich erst auf einer anderen Maschine als der, auf der er entstand.
+ */
+ it('liest die physische Taste und nicht das Zeichen, das sie erzeugt', () => {
+ expect(menuShortcutFor({ key: 'å', code: 'KeyA', altKey: true }, BODY, WIRED_MENU_SHORTCUTS))
+ .toBe('a');
+ });
+
+ it('gilt auch waehrend jemand tippt: dafuer traegt es den Modifikator', () => {
+ expect(menuShortcutFor(alt('a'), { tagName: 'INPUT' }, WIRED_MENU_SHORTCUTS)).toBe('a');
+ expect(menuShortcutFor(alt('a'), { tagName: 'TEXTAREA' }, WIRED_MENU_SHORTCUTS)).toBe('a');
+ });
+
+ it('macht aus einem blanken Buchstaben kein Kuerzel mehr', () => {
+ expect(menuShortcutFor({ key: 'a', code: 'KeyA' }, BODY, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ expect(menuShortcutFor({ key: 'c', code: 'KeyC' }, BODY, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ });
+
+ it('laesst die Kombinationen des Systems in Ruhe', () => {
+ expect(menuShortcutFor({ ...alt('a'), metaKey: true }, BODY, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ expect(menuShortcutFor({ ...alt('a'), ctrlKey: true }, BODY, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ });
+
+ it('liest ein verbrauchtes Ereignis nicht ein zweites Mal', () => {
+ expect(menuShortcutFor({ ...alt('a'), defaultPrevented: true }, BODY, WIRED_MENU_SHORTCUTS))
+ .toBeUndefined();
+ });
+
+ it('kennt nur die Buchstaben, die wirklich verdrahtet sind', () => {
+ expect(menuShortcutFor(alt('f'), BODY, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ expect(menuShortcutFor({ key: 'Enter' }, BODY, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ });
+
+ it('haelt die Hilfetaste blank, weil sie kein Wort beginnt', () => {
+ expect(RESERVED_BARE_SHORTCUTS).toEqual(['?']);
+ expect(menuShortcutFor({ key: '?' }, BODY, WIRED_MENU_SHORTCUTS)).toBe('?');
+ // In einem Feld ist ein Fragezeichen ein Fragezeichen.
+ expect(menuShortcutFor({ key: '?' }, { tagName: 'INPUT' }, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ });
+});
+
+describe('commandLineIntent', () => {
+
+ it('schickt jeden blanken Buchstaben in die Zeile', () => {
+ for (const letter of [...'create']) {
+ expect(commandLineIntent({ key: letter }, BODY)).toEqual({ kind: 'type', text: letter });
+ }
+ });
+
+ it('holt die Zeile mit der Fokustaste, ohne sie hineinzuschreiben', () => {
+ expect(commandLineIntent({ key: FOCUS_COMMAND_KEY }, BODY)).toEqual({ kind: 'focus' });
+ });
+
+ it('laesst das Leerzeichen in Ruhe: es bedient Knoepfe und den Baum', () => {
+ expect(commandLineIntent({ key: ' ' }, BODY)).toBeUndefined();
+ });
+
+ it('nimmt keine Taste, die schon jemandem gehoert', () => {
+ expect(commandLineIntent({ key: '?' }, BODY)).toBeUndefined();
+ expect(commandLineIntent(alt('a'), BODY)).toBeUndefined();
+ expect(commandLineIntent({ key: 'a', metaKey: true }, BODY)).toBeUndefined();
+ expect(commandLineIntent({ key: 'a', ctrlKey: true }, BODY)).toBeUndefined();
+ expect(commandLineIntent({ key: 'a', defaultPrevented: true }, BODY)).toBeUndefined();
+ });
+
+ it('nimmt nichts, was kein Zeichen ist', () => {
+ for (const key of ['Enter', 'Escape', 'ArrowDown', 'Tab', 'F5', 'Shift']) {
+ expect(commandLineIntent({ key }, BODY), key).toBeUndefined();
+ }
+ });
+
+ it('mischt sich nicht ein, wo schon getippt wird', () => {
+ expect(commandLineIntent({ key: 'a' }, { tagName: 'INPUT' })).toBeUndefined();
+ expect(commandLineIntent({ key: 'a' }, { tagName: 'TEXTAREA' })).toBeUndefined();
+ });
+});
+
+/**
+ * Die Naht zwischen dem, was auf einem Menuepunkt STEHT, und dem, was der Griff
+ * am Fenster HOERT.
+ *
+ * Befund 12 des unabhaengigen Audits vom 2026-08-29: die vier Eintraege der
+ * Atlas-Zeile trugen keinen Buchstaben, obwohl PLAN Abschnitt 4 verlangt, dass
+ * jeder Menuepunkt seinen traegt. Der Fehler, gegen den dieser Block geschnitten
+ * ist, ist der naechste: ein Etikett, das `[b]ug hunt` sagt, waehrend `b` nichts
+ * tut, ist schlimmer als eines ohne Klammer, weil es eine Bedienung verspricht.
+ */
+describe('jeder Eintrag der Atlas-Zeile traegt einen Buchstaben, der etwas tut', () => {
+
+ /** Der Buchstabe in der Klammer, oder nichts. */
+ const letterOf = (label: string): string | undefined => /\[([a-z])\]/.exec(label)?.[1];
+
+ const entries = [
+ { name: 'why', label: WHY_MENU_LABEL, expected: 'w' },
+ { name: 'bug', label: BUG_WIZARD_MENU_LABEL, expected: 'b' },
+ { name: 'llm off', label: llmMenuLabel('off'), expected: 'l' },
+ { name: 'llm on', label: llmMenuLabel('ready'), expected: 'l' },
+ ];
+
+ for (const entry of entries) {
+ it(`schreibt den Buchstaben von ${entry.name} in sein Etikett`, () => {
+ expect(letterOf(entry.label), entry.label).toBe(entry.expected);
+ });
+
+ it(`laesst den Buchstaben von ${entry.name} auch wirklich gelten`, () => {
+ const letter = letterOf(entry.label) ?? '';
+ expect(WIRED_MENU_SHORTCUTS, `${entry.label} verspricht ${letter}`).toContain(letter);
+ expect(menuShortcutFor(alt(letter), BODY, WIRED_MENU_SHORTCUTS)).toBe(letter);
+ });
+ }
+
+ /*
+ * Die Gegenprobe zur obersten Zeile. Sie trug bis zum 2026-08-29 vier
+ * Punkte ohne Verdrahtung; seit W7a gibt es sie nicht mehr, und ihre
+ * Buchstaben duerfen auch nicht im Vorbeigehen wieder belegt werden.
+ */
+ it('belegt keinen Buchstaben der geloeschten Attrappen', () => {
+ for (const letter of ['f', 'e', 'v', 't', 'c']) {
+ expect(WIRED_MENU_SHORTCUTS, `${letter} gehoerte einer Attrappe`).not.toContain(letter);
+ }
+ expect(WIRED_MENU_SHORTCUTS).toContain('a');
+ });
+});
diff --git a/graph-ui/src/app/keyboard.ts b/graph-ui/src/app/keyboard.ts
new file mode 100644
index 000000000..9d0039378
--- /dev/null
+++ b/graph-ui/src/app/keyboard.ts
@@ -0,0 +1,275 @@
+/**
+ * Wer eine Taste bekommt: die Kommandozeile, ein Menuekuerzel, oder niemand.
+ *
+ * ## Der Befund, gegen den diese Datei geschnitten ist (2026-08-29)
+ *
+ * Gemessen an der laufenden Vorschau: nach dem Laden liegt der Fokus auf `BODY`.
+ * Wer dann lostippt, sieht in der Kommandozeile NICHTS ankommen, und im
+ * Hintergrund gehen Panels auf, weil die blanken Buchstaben Menuekuerzel waren:
+ * das Wort "create" oeffnete ueber sein `c` die Aenderungsansicht. Aus
+ * Nutzersicht ist das "ich tippe und es passiert nichts", waehrend in Wahrheit
+ * etwas passiert, das niemand wollte. Beides zusammen ist die schlimmste Sorte
+ * Fehler: die Oberflaeche tut etwas anderes als das, was der Leser sieht.
+ *
+ * ## Die Regel, und warum diese und nicht die andere
+ *
+ * Der Contract stellte zwei Wege zur Wahl: (a) die Menuekuerzel auf
+ * Alt/Option umstellen, oder (b) type-to-search, bei dem blanke Buchstaben in
+ * die Zeile fallen und die Panel-Kuerzel eine Bestaetigung brauchen. Gewaehlt
+ * ist (a) ALS TRAEGER und (b) als notwendige Ergaenzung, und das ist keine
+ * Verwaesserung der Wahl, sondern ihr Ergebnis: (a) allein loest nur die zweite
+ * Haelfte des Befundes. Waeren die Buchstaben nur frei, wuerde Tippen ohne Klick
+ * weiterhin nichts tun, und genau das ist der Satz, den der Nutzer geschrieben
+ * hat. Und sobald blanke Buchstaben in die Zeile fallen, MUSS ein Kuerzel einen
+ * Modifikator tragen, sonst waere es unerreichbar, sobald die Zeile den Fokus
+ * hat. Die beiden Wege sind also nicht Alternativen, sondern die zwei Haelften
+ * derselben Loesung; die Wahl liegt darin, welcher Modifikator es ist, und das
+ * ist Alt/Option, weil genau das die Konvention einer Menueleiste ist.
+ *
+ * Also gilt, an einer Stelle und deshalb pruefbar:
+ *
+ * 1. **Alt/Option + Buchstabe ist ein Menuekuerzel**, ueberall, auch waehrend
+ * jemand tippt. Gelesen wird `event.code` und nicht `event.key`: unter macOS
+ * erzeugt Option+A ein `å`, und ein Kuerzel, das auf den Buchstaben schaut,
+ * waere dort taub.
+ * 2. **`?` bleibt ohne Modifikator die Hilfe**, solange nirgends getippt wird.
+ * Es ist die eine Ausnahme, und sie ist begruendet: `?` ist seit
+ * Jahrzehnten die Hilfetaste, es beginnt kein Wort, das jemand sucht, und
+ * sobald die Zeile den Fokus hat, tippt es sich ganz normal.
+ * 3. **`/` holt die Kommandozeile**, ohne selbst in ihr zu landen. Dieselbe
+ * Konvention wie anderswo, und die Hilfe nennt sie.
+ * 4. **Jedes andere druckbare Zeichen gehoert der Kommandozeile.** Die
+ * Oberflaeche holt sie sich und schreibt das Zeichen hinein.
+ * 5. **Das Leerzeichen nicht.** Es aktiviert einen Knopf und klappt eine
+ * Baumzeile auf; es zu stehlen hiesse, die Bedienung mit der Tastatur
+ * ueberall sonst kaputtzumachen, und keine Suche beginnt mit einem
+ * Leerzeichen.
+ *
+ * Was WEITER gilt und hier nicht steht: die vier Tasten einer laufenden
+ * Fuehrung (tour-player.ts) bleiben blank. Ihre Karte steht auf dem Bildschirm
+ * und schreibt sie hin; wer eine Fuehrung laufen laesst, sieht `[q] exit` und
+ * `[d] diagram` vor sich. Die Oberflaeche laesst der Fuehrung ihre vier Tasten
+ * und schickt alles andere in die Zeile (siehe App.tsx, dort steht die
+ * Reihenfolge).
+ */
+
+/** Nur das, was diese Pruefung von einem Ereignisziel braucht. */
+export interface KeyTargetLike {
+ tagName?: string;
+ isContentEditable?: boolean;
+ /**
+ * Der naechste Vorfahr, der zu diesem Selektor passt.
+ *
+ * Optional, damit ein Test ein Ziel als schlichtes Objekt hinstellen kann;
+ * im Browser bringt jedes Element die Funktion mit. Gebraucht wird sie fuer
+ * genau eine Frage, und die steht bei {@link EDITOR_SURFACE}.
+ */
+ closest?: (selector: string) => unknown;
+}
+
+/**
+ * Die eine fremde Klasse, die diese Datei kennt, und warum sie sie kennen muss.
+ *
+ * Der Kopf dieser Datei sagt seit W7b: "Monaco faellt darunter, ohne dass es
+ * hier erwaehnt werden muesste: der Editor haengt seine Tastatur an ein
+ * verstecktes `textarea`". Das stimmte, als der Satz geschrieben wurde, und
+ * stimmt seit Monaco 0.56 nicht mehr: der Editor haengt sie an ein `div` mit
+ * der EditContext-API. Damit war die Regel taub, wo sie am wichtigsten ist, und
+ * das war an einem gruenen Testlauf nicht zu sehen, aber im Beweislauf von W2a:
+ * ein Tastendruck IM Editor landete in der Kommandozeile, und das darauf
+ * folgende Enter oeffnete den ersten Suchtreffer. Gemessen am Stand vor W8
+ * (git archive HEAD, eigener Bau, derselbe Lauf): `lengthBefore 1437,
+ * lengthAfter 637`, also eine andere Datei im Reader. Der Befund ist damit
+ * aelter als dieser Zyklus und wird hier repariert, weil er sonst eine
+ * Zusicherung aus W2a stillschweigend verfallen liesse.
+ *
+ * Warum eine Klasse einer fremden Bibliothek und nicht ein eigenes Attribut:
+ * das Ziel eines Tastendrucks ist ein Element, das Monaco selbst erzeugt und
+ * austauscht, und ein Attribut, das dieses Produkt daran haengt, waere ein
+ * Attribut, das die naechste Fassung der Bibliothek wieder wegwirft. Die Klasse
+ * ist der einzige Anker, den Monaco ueber alle seine inneren Umbauten hinweg
+ * behaelt. Dieselbe Ueberlegung, aus demselben Grund, steht in
+ * tools/lib/readability.mjs, wo der Editor an genau dieser Klasse von der
+ * Messung ausgenommen ist.
+ */
+export const EDITOR_SURFACE = '.monaco-editor';
+
+/** Nur das, was diese Pruefung von einem Tastenereignis braucht. */
+export interface KeyEventLike {
+ key: string;
+ /** Die physische Taste. Sie traegt unter macOS auch dann `KeyA`, wenn `key` `å` sagt. */
+ code?: string;
+ metaKey?: boolean;
+ ctrlKey?: boolean;
+ altKey?: boolean;
+ shiftKey?: boolean;
+ defaultPrevented?: boolean;
+}
+
+/**
+ * Die eine druckbare Taste, die ohne Modifikator ein Kuerzel bleibt.
+ *
+ * Als Liste und nicht als Vergleich mit `'?'`, damit die Ausnahme zaehlbar ist:
+ * eine zweite Taste hier hinein zu schreiben, ist eine Entscheidung, die man
+ * sieht.
+ */
+export const RESERVED_BARE_SHORTCUTS: readonly string[] = ['?'];
+
+/** Die Taste, die die Kommandozeile holt, ohne selbst in ihr zu landen. */
+export const FOCUS_COMMAND_KEY = '/';
+
+/**
+ * Wie der Griff am Fenster haengt: in der EINFANGENDEN Phase.
+ *
+ * Seit dem 2026-08-29, und der Grund ist ein Nutzerbefund, der sich nicht
+ * nachstellen liess ("alt plus letter funktioniert nur fuer atlas"). Er ist an
+ * der laufenden Vorschau nicht aufgetreten, also wird hier nicht an einer
+ * Ursache geraten, sondern die eine Klasse von Ursachen ausgeschlossen, die
+ * eine Anwendung selbst in der Hand hat: ein Zwischenhaendler auf dem Weg nach
+ * oben. In der aufsteigenden Phase sieht das Fenster einen Tastendruck als
+ * LETZTER; jeder Griff an einem Element dazwischen (Monaco, ein Panel, eine
+ * Bibliothek) kann ihn vorher abbestellen oder das Aufsteigen abbrechen, und
+ * dann kommt das Kuerzel nie an. In der einfangenden Phase sieht das Fenster
+ * ihn als ERSTER, und keiner dieser Wege kann ihm zuvorkommen.
+ *
+ * Als Konstante und nicht als literales `true` an der Aufrufstelle, weil
+ * `removeEventListener` dieselbe Angabe braucht: ein Griff, der mit `capture`
+ * angemeldet und ohne abgemeldet wird, bleibt haengen, und das ist ein Fehler,
+ * den man erst nach dem zweiten Aufhaengen sieht.
+ */
+export const KEY_LISTENER_OPTIONS = { capture: true } as const;
+
+/**
+ * Ob an diesem Ziel gerade getippt wird.
+ *
+ * Drei Wege hinein: ein Feld, das man beschreiben kann, ein editierbarer
+ * Bereich, und die Flaeche des Editors. Der dritte steht ausdruecklich da und
+ * nicht nur als Folge des ersten; der Grund steht bei {@link EDITOR_SURFACE}.
+ */
+export function isTypingTarget(target: KeyTargetLike | null | undefined): boolean {
+ if (target === null || target === undefined) {
+ return false;
+ }
+ if (target.isContentEditable === true) {
+ return true;
+ }
+ const tag = (target.tagName ?? '').toUpperCase();
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
+ return true;
+ }
+ return target.closest?.(EDITOR_SURFACE) != null;
+}
+
+/** Der Buchstabe hinter einer physischen Taste, oder nichts. */
+function letterOfCode(code: string): string | undefined {
+ const match = /^Key([A-Z])$/.exec(code);
+ return match === null ? undefined : match[1].toLowerCase();
+}
+
+/**
+ * Das Menuekuerzel eines Tastendrucks, oder nichts.
+ *
+ * Zwei Wege hinein, und beide sind oben begruendet: Alt/Option + Buchstabe
+ * ueberall, und die reservierte blanke Taste, solange nirgends getippt wird.
+ */
+export function menuShortcutFor(
+ event: KeyEventLike,
+ target: KeyTargetLike | null | undefined,
+ shortcuts: readonly string[],
+): string | undefined {
+ if (event.defaultPrevented === true) {
+ return undefined;
+ }
+ if (event.metaKey === true || event.ctrlKey === true) {
+ return undefined;
+ }
+ if (event.altKey === true) {
+ const letter = letterOfCode(event.code ?? '');
+ return letter !== undefined && shortcuts.includes(letter) ? letter : undefined;
+ }
+ if (isTypingTarget(target)) {
+ return undefined;
+ }
+ // `?? ''` und nicht `event.key.toLowerCase()`: ein Ereignis ohne `key` ist
+ // im Browser nicht vorgesehen und kommt aus Bruecken (Erweiterungen,
+ // Automatisierung, synthetische Ereignisse) trotzdem an. Ein Griff, der
+ // daran wirft, verschluckt die Taste UND alle danach, und das saehe von
+ // aussen genau so aus wie der Nutzerbefund vom 2026-08-29.
+ const key = (event.key ?? '').toLowerCase();
+ return RESERVED_BARE_SHORTCUTS.includes(key) && shortcuts.includes(key) ? key : undefined;
+}
+
+/** Was ein blanker Tastendruck fuer die Kommandozeile bedeutet. */
+export type CommandLineIntent =
+ /** Die Zeile holen, ohne etwas hineinzuschreiben. */
+ | { kind: 'focus' }
+ /** Die Zeile holen und dieses Zeichen anhaengen. */
+ | { kind: 'type'; text: string };
+
+/**
+ * Ob dieser Tastendruck der Kommandozeile gehoert, und was sie damit tut.
+ *
+ * Sie bekommt ihn nur, wenn sonst niemand ihn hat: kein Modifikator, kein
+ * Eingabefeld unter dem Zeiger, kein reserviertes Kuerzel, und ein Zeichen, das
+ * man wirklich sieht. Alles andere (Enter, Escape, die Pfeile, F-Tasten) traegt
+ * einen Namen mit mehr als einem Zeichen und faellt hier von selbst heraus.
+ */
+export function commandLineIntent(
+ event: KeyEventLike,
+ target: KeyTargetLike | null | undefined,
+): CommandLineIntent | undefined {
+ if (event.defaultPrevented === true) {
+ return undefined;
+ }
+ if (event.metaKey === true || event.ctrlKey === true || event.altKey === true) {
+ return undefined;
+ }
+ if (isTypingTarget(target)) {
+ return undefined;
+ }
+ const key = event.key ?? '';
+ if (key.length !== 1 || key === ' ') {
+ return undefined;
+ }
+ if (RESERVED_BARE_SHORTCUTS.includes(key.toLowerCase())) {
+ return undefined;
+ }
+ return key === FOCUS_COMMAND_KEY ? { kind: 'focus' } : { kind: 'type', text: key };
+}
+
+/**
+ * Ob eine Taste waehrend einer laufenden Fuehrung die Fuehrung meint.
+ *
+ * Fast dieselbe Frage wie oben und mit einem bewussten Unterschied: ein
+ * `textarea` zaehlt hier NICHT als Tippen. Monaco haengt seine Tastatur an ein
+ * verstecktes `textarea`, und der Reader dieses Projekts ist read-only, also
+ * gibt es dort nichts zu tippen, was ein Enter verbrauchen koennte. Wer im
+ * Editor steht und Enter drueckt, meint den naechsten Schritt; wuerde die
+ * Fuehrung dort schweigen, muesste der Leser vor jedem Schritt erst aus dem
+ * Editor herausklicken.
+ *
+ * Ein echtes Eingabefeld ist die Ausnahme, die die Regel traegt: in der
+ * Kommandozeile und im Suchfeld des Einstiegsdialogs ist Enter das Absenden
+ * der Eingabe, und ein `q` ist der Buchstabe q.
+ */
+export function tourKeyForEvent(
+ event: KeyEventLike,
+ target: KeyTargetLike | null | undefined,
+): string | undefined {
+ if (event.defaultPrevented === true) {
+ return undefined;
+ }
+ if (event.metaKey === true || event.ctrlKey === true || event.altKey === true) {
+ return undefined;
+ }
+ if (target !== null && target !== undefined) {
+ if (target.isContentEditable === true) {
+ return undefined;
+ }
+ const tag = (target.tagName ?? '').toUpperCase();
+ if (tag === 'INPUT' || tag === 'SELECT') {
+ return undefined;
+ }
+ }
+ return event.key;
+}
diff --git a/graph-ui/src/app/module-qn.test.ts b/graph-ui/src/app/module-qn.test.ts
new file mode 100644
index 000000000..bf05ca326
--- /dev/null
+++ b/graph-ui/src/app/module-qn.test.ts
@@ -0,0 +1,107 @@
+/*
+ * Die Ableitung Pfad -> qualifizierter Name, gegen die Namen geprueft, die ein
+ * laufender Server am 2026-08-28 fuer fixtures/atlas-sample wirklich gefuehrt
+ * hat (`MATCH (n:Module) RETURN n.qualified_name, n.file_path`, Projekt
+ * `probe-small`). Die Faelle unten sind abgeschrieben, nicht ausgedacht: an
+ * ihnen haengt, ob der Reader die richtige Datei laedt.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+ baseName,
+ fileQualifiedName,
+ moduleQnFromFileQn,
+ moduleQualifiedName,
+ normalizeWorkspacePath,
+ pathSegments,
+ stripLastExtension,
+} from './module-qn';
+
+const PROJECT = 'probe-small';
+
+describe('moduleQualifiedName', () => {
+
+ it('bildet die Namen, die der Graph fuer das Fixture fuehrt', () => {
+ expect(moduleQualifiedName(PROJECT, 'src/services/userService.ts'))
+ .toBe('probe-small.src.services.userService');
+ expect(moduleQualifiedName(PROJECT, 'src/repo/db.ts')).toBe('probe-small.src.repo.db');
+ expect(moduleQualifiedName(PROJECT, 'src/types.ts')).toBe('probe-small.src.types');
+ });
+
+ it('nimmt genau eine Endung weg, nicht alle', () => {
+ // Der Graph fuehrt diese Datei als probe-small.test.userService.test.
+ expect(moduleQualifiedName(PROJECT, 'test/userService.test.ts'))
+ .toBe('probe-small.test.userService.test');
+ });
+
+ it('behandelt eine Datei ohne Verzeichnis wie eine mit', () => {
+ expect(moduleQualifiedName(PROJECT, 'HERKUNFT.md')).toBe('probe-small.HERKUNFT');
+ });
+
+ it('laesst einen Pfad ohne Endung stehen', () => {
+ expect(moduleQualifiedName(PROJECT, 'scripts/Makefile')).toBe('probe-small.scripts.Makefile');
+ });
+
+ it('haengt sich nicht an einem Punkt im Verzeichnisnamen auf', () => {
+ expect(moduleQualifiedName(PROJECT, '.github/workflows/ci.yml'))
+ .toBe('probe-small..github.workflows.ci');
+ });
+
+ it('normalisiert fuehrende Schraegstriche und ./ weg', () => {
+ expect(moduleQualifiedName(PROJECT, './src/types.ts')).toBe('probe-small.src.types');
+ expect(moduleQualifiedName(PROJECT, '/src/types.ts')).toBe('probe-small.src.types');
+ });
+});
+
+describe('stripLastExtension', () => {
+
+ it('laesst einen Namen stehen, der nur aus einer Endung besteht', () => {
+ // .gitignore ohne Endung waere ein leerer Name, und ein leerer Name ist
+ // kein Symbol.
+ expect(stripLastExtension('.gitignore')).toBe('.gitignore');
+ expect(stripLastExtension('src/.gitignore')).toBe('src/.gitignore');
+ });
+});
+
+describe('fileQualifiedName und der Rueckweg', () => {
+
+ it('bildet den File-Knoten so, wie der Graph ihn fuehrt', () => {
+ expect(fileQualifiedName(PROJECT, 'src/services/userService.ts'))
+ .toBe('probe-small.src.services.userService.ts.__file__');
+ });
+
+ it('rechnet vom File-Knoten auf den Modul-Knoten zurueck', () => {
+ expect(moduleQnFromFileQn('probe-small.src.services.userService.ts.__file__'))
+ .toBe('probe-small.src.services.userService');
+ expect(moduleQnFromFileQn('probe-small.test.userService.test.ts.__file__'))
+ .toBe('probe-small.test.userService.test');
+ });
+
+ it('gibt nichts zurueck, wenn der Name kein File-Knoten ist', () => {
+ expect(moduleQnFromFileQn('probe-small.src.services.userService')).toBeUndefined();
+ });
+
+ it('ist zur Ableitung aus dem Pfad deckungsgleich', () => {
+ for (const path of ['src/services/userService.ts', 'test/userService.test.ts', 'HERKUNFT.md']) {
+ expect(moduleQnFromFileQn(fileQualifiedName(PROJECT, path)))
+ .toBe(moduleQualifiedName(PROJECT, path));
+ }
+ });
+});
+
+describe('Pfad-Hilfen der Anzeige', () => {
+
+ it('zerlegt einen Pfad in die Segmente der Breadcrumb', () => {
+ expect(pathSegments('src/services/userService.ts'))
+ .toEqual(['src', 'services', 'userService.ts']);
+ });
+
+ it('nennt den Dateinamen fuer den Tab', () => {
+ expect(baseName('src/services/userService.ts')).toBe('userService.ts');
+ expect(baseName('package.json')).toBe('package.json');
+ });
+
+ it('normalisiert Rueckwaerts-Schraegstriche mit', () => {
+ expect(normalizeWorkspacePath('src\\services\\userService.ts'))
+ .toBe('src/services/userService.ts');
+ });
+});
diff --git a/graph-ui/src/app/module-qn.ts b/graph-ui/src/app/module-qn.ts
new file mode 100644
index 000000000..1ee37b6cf
--- /dev/null
+++ b/graph-ui/src/app/module-qn.ts
@@ -0,0 +1,101 @@
+/**
+ * Vom Dateipfad zum qualifizierten Namen, den der Graph fuer diese Datei fuehrt.
+ *
+ * Der Reader braucht diese Umrechnung, weil der Server keinen Endpunkt hat, der
+ * in diesem Providerpfad eine Datei ausliefert. Was es gibt,
+ * ist `get_code_snippet` auf einem indizierten Symbol, und der Modul-Knoten ist
+ * das Symbol, das die ganze Datei umspannt.
+ *
+ * Die Regel ist an einem laufenden Server abgelesen, nicht geraten
+ * (fixtures/atlas-sample, indiziert, `MATCH (n:Module) RETURN n.qualified_name,
+ * n.file_path`):
+ *
+ * src/services/userService.ts -> .src.services.userService
+ * test/userService.test.ts -> .test.userService.test
+ * HERKUNFT.md -> .HERKUNFT
+ *
+ * Also: Schraegstriche werden zu Punkten, und **genau eine** Endung faellt weg,
+ * die letzte. `userService.test.ts` behaelt sein `.test`, weil der Graph es
+ * behaelt.
+ *
+ * Der File-Knoten derselben Datei traegt den ganzen Pfad samt Endung und haengt
+ * `.__file__` an:
+ *
+ * src/services/userService.ts -> .src.services.userService.ts.__file__
+ *
+ * Damit ist die Ableitung gegenpruefbar, und der Reader prueft sie auch: er
+ * fragt den Graphen nach dem Modul-Knoten dieser Datei und nimmt dessen Namen,
+ * wenn er von der Ableitung abweicht. Die Ableitung ist der schnelle Weg, der
+ * Graph bleibt die Wahrheit.
+ */
+
+/** Was `.__file__` an einen File-Knoten haengt. */
+export const FILE_NODE_SUFFIX = '.__file__';
+
+/** Ein Pfad ohne fuehrenden Schraegstrich und ohne `./`. */
+export function normalizeWorkspacePath(path: string): string {
+ let value = path.trim().replace(/\\/g, '/');
+ while (value.startsWith('./')) {
+ value = value.slice(2);
+ }
+ while (value.startsWith('/')) {
+ value = value.slice(1);
+ }
+ return value;
+}
+
+/**
+ * Der Pfad ohne seine letzte Endung.
+ *
+ * Ein Punkt im Verzeichnisteil zaehlt nicht (`.github/workflows/ci.yml` verliert
+ * `.yml`, nicht `.github/...`), und ein Name, der nur aus einer Endung besteht
+ * (`.gitignore`), behaelt sie: sonst bliebe ein leerer Name uebrig.
+ */
+export function stripLastExtension(path: string): string {
+ const slash = path.lastIndexOf('/');
+ const base = slash === -1 ? path : path.slice(slash + 1);
+ const dot = base.lastIndexOf('.');
+ if (dot <= 0) {
+ return path;
+ }
+ return (slash === -1 ? '' : path.slice(0, slash + 1)) + base.slice(0, dot);
+}
+
+/** Der qualifizierte Name des Modul-Knotens, den der Graph fuer diese Datei fuehrt. */
+export function moduleQualifiedName(project: string, filePath: string): string {
+ const path = normalizeWorkspacePath(filePath);
+ const withoutExtension = stripLastExtension(path);
+ return `${project}.${withoutExtension.split('/').join('.')}`;
+}
+
+/** Der qualifizierte Name des File-Knotens derselben Datei. */
+export function fileQualifiedName(project: string, filePath: string): string {
+ const path = normalizeWorkspacePath(filePath);
+ return `${project}.${path.split('/').join('.')}${FILE_NODE_SUFFIX}`;
+}
+
+/**
+ * Der Modul-Name, der zu einem File-Knoten gehoert: Suffix ab, Endung ab.
+ *
+ * Das ist der Rueckweg und er existiert, damit die Ableitung gegen eine Antwort
+ * des Servers gehalten werden kann, statt gegen eine zweite Ableitung.
+ */
+export function moduleQnFromFileQn(fileQualifiedNameValue: string): string | undefined {
+ if (!fileQualifiedNameValue.endsWith(FILE_NODE_SUFFIX)) {
+ return undefined;
+ }
+ const withExtension = fileQualifiedNameValue.slice(0, -FILE_NODE_SUFFIX.length);
+ const dot = withExtension.lastIndexOf('.');
+ return dot <= 0 ? withExtension : withExtension.slice(0, dot);
+}
+
+/** Die Segmente eines Pfades, wie die Breadcrumb sie zeigt. */
+export function pathSegments(filePath: string): string[] {
+ return normalizeWorkspacePath(filePath).split('/').filter((segment) => segment.length > 0);
+}
+
+/** Der Dateiname ohne Verzeichnisse. Der Name eines Tabs. */
+export function baseName(filePath: string): string {
+ const segments = pathSegments(filePath);
+ return segments.length === 0 ? filePath : segments[segments.length - 1];
+}
diff --git a/graph-ui/src/app/project-windows.test.tsx b/graph-ui/src/app/project-windows.test.tsx
new file mode 100644
index 000000000..89de6c002
--- /dev/null
+++ b/graph-ui/src/app/project-windows.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment jsdom
+import { act, useEffect } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { ProjectWindows, useParamInAddress, type ProjectWindow } from './project-windows';
+
+/*
+ * K24: a project switch stays in the page. Each project gets a fresh window (fresh state in
+ * every panel, as after a reload), the address changes with the history API, Back and Forward
+ * switch the same way, and requests of the old window are cancelled.
+ */
+
+let container: HTMLDivElement;
+let root: Root;
+beforeEach(() => {
+ (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true;
+ window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy');
+ container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
+});
+afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); });
+
+interface Carry { chatOpen: boolean }
+const mounted: string[] = [];
+const unmounted: string[] = [];
+let current: ProjectWindow | undefined;
+function Window({ window: shown }: { window: ProjectWindow }) {
+ current = shown;
+ useEffect(() => { mounted.push(shown.project); return () => { unmounted.push(shown.project); }; }, []);
+ useEffect(() => { shown.report({ chatOpen: shown.project === 'django-demo' }); });
+ return
{shown.project}:{String(shown.carried?.chatOpen)}
;
+}
+const shownProject = () => container.querySelector('p')?.getAttribute('data-project');
+
+describe('an in-page project switch (K24)', () => {
+ beforeEach(() => { mounted.length = 0; unmounted.length = 0; current = undefined; });
+
+ it('opens another project without reloading: new address, a fresh window, the old one cancelled', async () => {
+ const push = vi.spyOn(window.history, 'pushState');
+ await act(async () => root.render(>{shown => }));
+ expect(shownProject()).toBe('django-demo');
+ const first = current!;
+ await act(async () => first.open('cbm'));
+ expect(push).toHaveBeenCalledOnce();
+ expect(window.location.search).toBe('?project=cbm');
+ expect(shownProject()).toBe('cbm');
+ expect(mounted).toEqual(['django-demo', 'cbm']);
+ expect(unmounted).toEqual(['django-demo']);
+ // The old window's requests are cancelled; the new window's are not.
+ expect(first.signal.aborted).toBe(true);
+ expect(current!.signal.aborted).toBe(false);
+ // What the old window reported travels to the new one.
+ expect(container.textContent).toBe('cbm:true');
+ });
+
+ it('stays put for the project already shown', async () => {
+ const push = vi.spyOn(window.history, 'pushState');
+ await act(async () => root.render(>{shown => }));
+ await act(async () => current!.open('django-demo'));
+ expect(push).not.toHaveBeenCalled();
+ expect(mounted).toEqual(['django-demo']);
+ });
+
+ it('switches back and forward with the browser history in the page', async () => {
+ await act(async () => root.render(>{shown => }));
+ await act(async () => current!.open('cbm'));
+ await act(async () => {
+ window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+ expect(shownProject()).toBe('django-demo');
+ expect(mounted).toEqual(['django-demo', 'cbm', 'django-demo']);
+ // A popstate that keeps the project (another query parameter) keeps the window.
+ await act(async () => {
+ window.history.replaceState(null, '', '/?project=django-demo&workspace=explore');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+ expect(mounted).toHaveLength(3);
+ });
+
+ /* Hand test 2026-10-04 (A5): the switcher turned "?project=django-demo&workspace=galaxy" into "?project=cbm". */
+ it('keeps the page-wide parameters it is given, the workspace among them, and nothing project specific', async () => {
+ window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py');
+ await act(async () => root.render( keepParams={['workspace', 'codeatlasClosureDepth', 'codeatlasClosureCap']}>
+ {shown => }));
+ await act(async () => current!.open('atlas sample'));
+ expect(window.location.search).toBe('?project=atlas%20sample&workspace=galaxy&codeatlasClosureDepth=4');
+ expect(shownProject()).toBe('atlas sample');
+ // Back and Forward of the browser still switch between both projects, each with its own address.
+ const popped = () => new Promise(resolve => window.addEventListener('popstate', () => resolve(), { once: true }));
+ await act(async () => { const done = popped(); window.history.back(); await done; });
+ expect(window.location.search).toBe('?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py');
+ expect(shownProject()).toBe('django-demo');
+ await act(async () => { const done = popped(); window.history.forward(); await done; });
+ expect(shownProject()).toBe('atlas sample');
+ });
+
+ it('writes the workspace in view into the address without a new history entry', async () => {
+ window.history.replaceState(null, '', '/?project=django-demo');
+ const length = window.history.length;
+ function Workspace({ name }: { name: string }) { useParamInAddress('workspace', name); return null; }
+ await act(async () => root.render());
+ expect(window.location.search).toBe('?project=django-demo&workspace=architecture');
+ await act(async () => root.render());
+ expect(window.location.search).toBe('?project=django-demo&workspace=galaxy');
+ expect(window.history.length).toBe(length);
+ });
+
+ it('commits the switch inside the frame it is given, so the old window is gone and the new one there when it ends', async () => {
+ const seen: string[] = [];
+ const around = (commit: () => void) => { seen.push(`before:${mounted.join(',')}`); commit(); seen.push(`after:${mounted.join(',')}|${unmounted.join(',')}`); };
+ await act(async () => root.render( around={around}>{shown => }));
+ await act(async () => current!.open('cbm'));
+ expect(seen).toEqual(['before:django-demo', 'after:django-demo,cbm|django-demo']);
+ });
+});
diff --git a/graph-ui/src/app/project-windows.tsx b/graph-ui/src/app/project-windows.tsx
new file mode 100644
index 000000000..73d1175af
--- /dev/null
+++ b/graph-ui/src/app/project-windows.tsx
@@ -0,0 +1,107 @@
+/**
+ * Ein Projektwechsel bleibt in der Seite (K24).
+ *
+ * Bis K24 lud ein Wechsel die ganze Seite neu, weil jedes Panel dieses Fensters
+ * an das Projekt gebunden ist, mit dem es startete. Mit dem Neuladen gingen der
+ * Worker des lokalen Agenten und seine Gewichte mit, und die Lampe stand
+ * sekundenlang auf "loading". Jetzt bekommt jedes Projekt ein frisches Fenster:
+ * derselbe Baum unter einem neuen Schluessel, also frischer Zustand in jedem
+ * Panel wie nach einem Neuladen, aber ohne die Seite zu verlassen.
+ *
+ * Drei Entscheidungen:
+ *
+ * 1. **Ein neuer Schluessel statt eines Zuruecksetzens.** Das Fenster haelt
+ * rund hundert Zustaende, und jeder vergessene waere eine Anzeige des alten
+ * Projekts im neuen. Ein neuer Schluessel vergisst keinen. Was das alte
+ * Fenster dem neuen mitgibt, meldet es ausdruecklich (`report`), und nur
+ * das kommt an (`carried`).
+ * 2. **Die Adresse wechselt mit pushState.** Zurueck und Vor des Browsers
+ * wechseln das Projekt ebenso in der Seite (popstate); ein popstate, das das
+ * Projekt behaelt, laesst das Fenster stehen.
+ * 3. **Das alte Fenster ist weg, wenn der Wechsel endet.** Der Wechsel wird mit
+ * flushSync festgeschrieben, innerhalb des Rahmens `around`, in dem der
+ * Agent sein Modell weiterreicht (browser-ai/agent-handover.ts). Danach
+ * bricht das Signal des alten Fensters ab: was es noch fragt, geht nicht
+ * mehr hinaus, und was noch antwortet, landet in einem ausgehaengten Baum.
+ */
+import { useCallback, useEffect, useRef, useState, type JSX, type ReactNode } from 'react';
+import { flushSync } from 'react-dom';
+import { projectHref } from '../projects/projects-model';
+
+/** Das Projekt aus `?project=`, leer, wenn die Adresse keins nennt. */
+export function projectOfLocation(search = window.location.search): string {
+ return new URLSearchParams(search).get('project') ?? '';
+}
+
+/*
+ * Handtest vom 2026-10-04 (A5): der Wechsel machte aus
+ * "?project=django-demo&workspace=galaxy" die Adresse "?project=cbm", und ein
+ * Neuladen danach konnte einen anderen Arbeitsbereich zeigen. Die Adresse des
+ * neuen Projekts behaelt nun die Parameter der ganzen Seite, die der Aufrufer
+ * nennt (den Arbeitsbereich, die Grenzen des Walks), und nichts, was zum alten
+ * Projekt gehoert.
+ */
+export function projectSwitchHref(project: string, search: string, keep: readonly string[]): string {
+ const from = new URLSearchParams(search);
+ const kept = keep.flatMap(name => { const value = from.get(name); return value === null ? [] : [`&${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; });
+ return `${projectHref(project)}${kept.join('')}`;
+}
+
+/** Haelt einen Parameter der Adresse auf dem Wert, den die Seite zeigt, ohne neuen Eintrag im Verlauf (A5: der Arbeitsbereich). */
+export function useParamInAddress(name: string, value: string): void {
+ useEffect(() => {
+ const url = new URL(window.location.href);
+ if (url.searchParams.get(name) === value) return;
+ url.searchParams.set(name, value);
+ window.history.replaceState(window.history.state, '', url);
+ }, [name, value]);
+}
+
+export interface ProjectWindow {
+ /** Wechselt mit jedem Projekt; als React-Schluessel des Fensters gedacht. */
+ key: number;
+ /** Das Projekt der Adresse beim Oeffnen des Fensters, leer ohne `?project=`. */
+ project: string;
+ /** Bricht ab, sobald ein anderes Projekt das Fenster ersetzt hat. */
+ signal: AbortSignal;
+ /** Was das vorige Fenster zuletzt gemeldet hat. */
+ carried?: C;
+ /** Oeffnet ein anderes Projekt in dieser Seite. */
+ open: (project: string) => void;
+ /** Meldet, was ein naechstes Fenster mitbekommen soll. */
+ report: (carry: C) => void;
+}
+
+interface Shown { key: number; project: string; controller: AbortController; carried?: C }
+
+export function ProjectWindows({ children, around = commit => commit(), keepParams = [] }: {
+ children: (window: ProjectWindow) => ReactNode;
+ around?: (commit: () => void) => void;
+ /** Parameter der ganzen Seite, die ein Wechsel in die neue Adresse mitnimmt (A5). */
+ keepParams?: readonly string[];
+}): JSX.Element {
+ const [shown, setShown] = useState>(() => ({ key: 0, project: projectOfLocation(), controller: new AbortController() }));
+ const current = useRef(shown); current.current = shown;
+ const carry = useRef(undefined);
+ const show = useCallback((project: string) => {
+ const previous = current.current;
+ if (project === previous.project) return;
+ const next: Shown = { key: previous.key + 1, project, controller: new AbortController(), ...carry.current === undefined ? {} : { carried: carry.current } };
+ current.current = next;
+ around(() => flushSync(() => setShown(next)));
+ previous.controller.abort();
+ }, [around]);
+ const keep = useRef(keepParams); keep.current = keepParams;
+ const open = useCallback((project: string) => {
+ if (project === current.current.project) return;
+ window.history.pushState(null, '', projectSwitchHref(project, window.location.search, keep.current));
+ show(project);
+ }, [show]);
+ const report = useCallback((value: C) => { carry.current = value; }, []);
+ useEffect(() => {
+ const back = () => show(projectOfLocation());
+ window.addEventListener('popstate', back);
+ return () => window.removeEventListener('popstate', back);
+ }, [show]);
+ return <>{children({ key: shown.key, project: shown.project, signal: shown.controller.signal, carried: shown.carried, open, report })}>;
+}
diff --git a/graph-ui/src/app/ready-twin-target.test.ts b/graph-ui/src/app/ready-twin-target.test.ts
new file mode 100644
index 000000000..d9d9135cb
--- /dev/null
+++ b/graph-ui/src/app/ready-twin-target.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+
+import type { SymbolRef } from '../core/focus-protocol';
+import type { SemanticIR } from '../core/semantic-ir';
+import { keepsReadyTwinForTarget } from './ready-twin-target';
+
+const symbol = (qualifiedName: string | undefined): SymbolRef => ({
+ name: 'createUser',
+ qualifiedName,
+ kind: 'function',
+ uri: 'file:///workspace/src/services/userService.ts',
+ range: { start: { line: 22, character: 0 }, end: { line: 35, character: 1 } },
+});
+
+const irFor = (value: SymbolRef): SemanticIR => ({ symbol: value } as SemanticIR);
+
+describe('keepsReadyTwinForTarget', () => {
+ it('keeps a ready IR for the same uniquely qualified target', () => {
+ const current = symbol('sample.user.createUser');
+
+ expect(keepsReadyTwinForTarget(current, current, irFor(current), 'ready')).toBe(true);
+ });
+
+ it('does not keep a ready twin for a different target', () => {
+ const current = symbol('sample.user.createUser');
+ const other = symbol('sample.user.validateUser');
+
+ expect(keepsReadyTwinForTarget(other, current, irFor(current), 'ready')).toBe(false);
+ });
+
+ it('does not keep a twin without a completed IR or unique target identity', () => {
+ const current = symbol('sample.user.createUser');
+
+ expect(keepsReadyTwinForTarget(current, current, undefined, 'ready')).toBe(false);
+ expect(keepsReadyTwinForTarget(symbol(undefined), current, irFor(current), 'ready')).toBe(false);
+ expect(keepsReadyTwinForTarget(current, current, irFor(current), 'loading')).toBe(false);
+ });
+});
diff --git a/graph-ui/src/app/ready-twin-target.ts b/graph-ui/src/app/ready-twin-target.ts
new file mode 100644
index 000000000..d31c06da1
--- /dev/null
+++ b/graph-ui/src/app/ready-twin-target.ts
@@ -0,0 +1,24 @@
+import type { SymbolRef } from '../core/focus-protocol';
+import type { SemanticIR } from '../core/semantic-ir';
+import type { TwinStatus } from '../twin/TwinPanel';
+
+/**
+ * Darf ein Follow den sichtbaren, fertigen Twin stehen lassen?
+ *
+ * Ein Name reicht nicht: ueberladene oder gleich benannte Symbole waeren sonst
+ * dieselbe Behauptung. Nur der qualifizierte Name bindet Ziel, sichtbares
+ * Subjekt und die bereits gebaute IR eindeutig zusammen.
+ */
+export function keepsReadyTwinForTarget(
+ target: SymbolRef,
+ shown: SymbolRef | undefined,
+ shownIr: SemanticIR | undefined,
+ status: TwinStatus,
+): boolean {
+ const qualifiedName = target.qualifiedName;
+ return status === 'ready'
+ && qualifiedName !== undefined
+ && qualifiedName.length > 0
+ && shown?.qualifiedName === qualifiedName
+ && shownIr?.symbol.qualifiedName === qualifiedName;
+}
diff --git a/graph-ui/src/app/shortcuts.test.ts b/graph-ui/src/app/shortcuts.test.ts
new file mode 100644
index 000000000..1f05edd50
--- /dev/null
+++ b/graph-ui/src/app/shortcuts.test.ts
@@ -0,0 +1,132 @@
+/**
+ * Die Naht zwischen dem, was in der Menuezeile STEHT, und dem, was die Tastatur
+ * wirklich hoert.
+ *
+ * Nutzerauftrag vom 2026-08-29: die Zeile trug `[f]ile [e]dit [v]iew
+ * [t]erminal`, vier Punkte, hinter denen nichts lag, mit einem Tooltip als
+ * Fussnote. Diese Datei haelt fest, dass das nicht wiederkommen kann, und zwar
+ * strukturell: geprueft wird nicht eine Liste erlaubter Punkte (die man
+ * erweitern kann, ohne etwas zu verdrahten), sondern die Beziehung zwischen den
+ * beiden Listen, die es wirklich gibt.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { messages } from '../i18n/messages';
+import { experimentalAgentsEnabled } from './feature-flags';
+import { playerIntent } from '../tours/tour-player';
+import { RESERVED_BARE_SHORTCUTS, menuShortcutFor } from './keyboard';
+import { ATLAS_SHORTCUTS, PROBED_KEYS, WIRED_MENU_SHORTCUTS, needsAlt, shortcutId } from './shortcuts';
+
+describe('die Menuezeile traegt nur, was etwas tut', () => {
+
+ it('gibt jedem Menuepunkt einen Buchstaben, der verdrahtet ist', () => {
+ for (const item of messages.menu.items) {
+ expect(WIRED_MENU_SHORTCUTS, `[${item.key}]${item.rest} ist nicht verdrahtet`)
+ .toContain(item.key);
+ }
+ });
+
+ it('traegt ueberhaupt Punkte, damit die Regel oben nicht leer laeuft', () => {
+ expect(messages.menu.items.length).toBeGreaterThanOrEqual(2);
+ expect(messages.menu.items.map((item) => item.key)).toEqual(['a', '?']);
+ });
+
+ it('hat die vier Attrappen des Vorbilds nicht mehr', () => {
+ const keys = messages.menu.items.map((item) => item.key);
+ for (const gone of ['f', 'e', 'v', 't']) {
+ expect(keys, `[${gone}] war ein Punkt ohne Verdrahtung`).not.toContain(gone);
+ }
+ });
+
+ it('kennt keinen Tooltip mehr fuer einen Punkt, der nichts tut', () => {
+ expect(Object.keys(messages.menu)).not.toContain('notWired');
+ });
+
+ it('vergibt keinen Buchstaben zweimal', () => {
+ expect(new Set(WIRED_MENU_SHORTCUTS).size).toBe(WIRED_MENU_SHORTCUTS.length);
+ });
+
+ it('laesst jedes Kuerzel mit Alt gelten, auch waehrend jemand tippt', () => {
+ for (const key of WIRED_MENU_SHORTCUTS.filter((entry) => !RESERVED_BARE_SHORTCUTS.includes(entry))) {
+ const event = { key, code: `Key${key.toUpperCase()}`, altKey: true };
+ expect(menuShortcutFor(event, { tagName: 'BODY' }, WIRED_MENU_SHORTCUTS)).toBe(key);
+ expect(menuShortcutFor(event, { tagName: 'INPUT' }, WIRED_MENU_SHORTCUTS)).toBe(key);
+ }
+ });
+
+ /*
+ * Die Kehrseite, und der eigentliche Nutzerbefund vom 2026-08-29: ein
+ * blanker Buchstabe ist Text. Er darf kein Panel oeffnen, weder hier noch
+ * irgendwo sonst, weil das Wort "create" sonst ueber sein "c" die
+ * Aenderungsansicht aufschlaegt, waehrend der Leser auf die leere
+ * Kommandozeile schaut.
+ */
+ it('macht aus keinem blanken Buchstaben ein Kuerzel', () => {
+ for (const key of WIRED_MENU_SHORTCUTS.filter((entry) => !RESERVED_BARE_SHORTCUTS.includes(entry))) {
+ const event = { key, code: `Key${key.toUpperCase()}` };
+ expect(menuShortcutFor(event, { tagName: 'BODY' }, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ }
+ });
+
+ it('haelt die reservierte Hilfetaste blank, aber nur ausserhalb eines Feldes', () => {
+ for (const key of RESERVED_BARE_SHORTCUTS) {
+ expect(menuShortcutFor({ key }, { tagName: 'BODY' }, WIRED_MENU_SHORTCUTS)).toBe(key);
+ expect(menuShortcutFor({ key }, { tagName: 'INPUT' }, WIRED_MENU_SHORTCUTS)).toBeUndefined();
+ }
+ });
+});
+
+describe('die Tastenliste der Hilfe ist die Verdrahtung selbst', () => {
+
+ it('liest die Tasten des Walks aus playerIntent statt sie aufzuschreiben', () => {
+ const walk = ATLAS_SHORTCUTS.filter((entry) => entry.scope === 'walk').map((entry) => entry.key);
+ expect(walk.length).toBeGreaterThanOrEqual(4);
+ for (const key of PROBED_KEYS) {
+ const wired = playerIntent(key) !== 'none';
+ expect(walk.includes(key), `${key}: Verdrahtung ${wired}, Hilfe ${walk.includes(key)}`)
+ .toBe(wired);
+ }
+ });
+
+ it('documents only the dedicated Galaxy search shortcut', () => {
+ expect(ATLAS_SHORTCUTS.filter(entry => entry.scope === 'galaxy')).toEqual([{ scope: 'galaxy', key: 'Cmd/Ctrl+K' }]);
+ expect(ATLAS_SHORTCUTS.some(entry => entry.key === '/')).toBe(false);
+ expect(WIRED_MENU_SHORTCUTS).not.toContain('c');
+ });
+
+ it('nimmt die Menuetasten aus genau der Liste, die das Fenster hoert', () => {
+ const menu = ATLAS_SHORTCUTS
+ .filter((entry) => entry.scope === 'mnemonic' || entry.scope === 'bare')
+ .map((entry) => entry.key);
+ expect(menu).toEqual([...WIRED_MENU_SHORTCUTS]);
+ });
+
+ it('trennt die Kuerzel mit Alt von der einen, die ohne gilt', () => {
+ const bare = ATLAS_SHORTCUTS.filter((entry) => entry.scope === 'bare').map((entry) => entry.key);
+ expect(bare).toEqual([...RESERVED_BARE_SHORTCUTS]);
+ for (const entry of ATLAS_SHORTCUTS.filter((item) => item.scope === 'mnemonic')) {
+ expect(needsAlt(entry)).toBe(true);
+ }
+ });
+
+ it('sagt zu jeder Taste, was sie tut, und zu keiner anderen', () => {
+ const documented = Object.keys(messages.help.shortcutDoes)
+ .filter(id => experimentalAgentsEnabled || id !== 'mnemonic:g').sort();
+ const shown = ATLAS_SHORTCUTS.map(shortcutId).sort();
+ expect(shown).toEqual(documented);
+ for (const id of shown) {
+ expect(messages.help.shortcutDoes[id]?.trim().length, `${id} hat keinen Satz`)
+ .toBeGreaterThan(0);
+ }
+ });
+
+ it('schreibt jede Taste so, wie sie auf einer Tastatur heisst', () => {
+ for (const shortcut of ATLAS_SHORTCUTS) {
+ const label = messages.help.keyNames[shortcut.key] ?? shortcut.key;
+ expect(label.trim().length).toBeGreaterThan(0);
+ // Kein DOM-Name in der Hilfe: `ArrowUp` steht auf keiner Taste.
+ expect(/^(Arrow|Page)/.test(label)).toBe(false);
+ }
+ });
+});
diff --git a/graph-ui/src/app/shortcuts.ts b/graph-ui/src/app/shortcuts.ts
new file mode 100644
index 000000000..55221eb33
--- /dev/null
+++ b/graph-ui/src/app/shortcuts.ts
@@ -0,0 +1,98 @@
+/** Shared menu and workspace shortcut catalog used by keyboard handling and help. */
+import { playerIntent } from '../tours/tour-player';
+import { RESERVED_BARE_SHORTCUTS } from './keyboard';
+
+/**
+ * Wo eine Taste gilt.
+ *
+ * Vier Bereiche, und der Unterschied zwischen den ersten beiden ist genau der,
+ * den ein Leser wissen muss: ein `mnemonic` traegt Alt/Option und gilt auch
+ * waehrend des Tippens, eine `bare` Taste gilt nur, solange nirgends getippt
+ * wird. Ein Bereich mehr ist billiger als eine Tabelle, die beides gleich
+ * aussehen laesst.
+ */
+import { experimentalAgentsEnabled } from './feature-flags';
+
+export type ShortcutScope = 'mnemonic' | 'bare' | 'walk' | 'galaxy';
+
+/** Eine Taste, die etwas tut, und der Bereich, in dem sie es tut. */
+export interface AtlasShortcut {
+ readonly scope: ShortcutScope;
+ /** Der Wert von `KeyboardEvent.key`, kleingeschrieben, wo es ein Buchstabe ist. */
+ readonly key: string;
+}
+
+/**
+ * Die Buchstaben, die wirklich etwas tun.
+ *
+ * `a` klappt die Galaxie auf und zu; die Eintraege der Atlas-Zeile tragen seit
+ * dem 2026-08-29 ihre eigenen: `w` die Frage nach dem Warum, `b` den
+ * BUG-Assistenten, `l` den Schalter des lokalen
+ * Modells, `r` seit W8 den Weg zurueck zum Vorgabe-Layout, `s` seit W10 das
+ * Einstellungen-Panel, `g` seit W11a den Live-Modus der Agenten, `p` das
+ * Dialog zum Anlegen eines Projektindexes.
+ * `?` schlaegt seit W7a die Hilfe auf und wieder zu.
+ *
+ * Warum das vorher nicht so war und warum es jetzt so ist: die Zeile war ein
+ * Menue, dessen Eintraege nur mit der Maus erreichbar waren, in einer
+ * Oberflaeche, deren ganzes Vorbild die Tastatur ist (PLAN Abschnitt 4, "jeder
+ * Menuepunkt traegt seinen Shortcut"). Das unabhaengige Audit hat es als
+ * Befund 12 aufgeschrieben.
+ *
+ * Die Gegenrichtung ist seit W7a die schaerfere Zusicherung: die Menuezeile
+ * traegt keinen Punkt mehr, der hier NICHT steht. Geprueft wird das strukturell
+ * ueber `messages.menu.items` (src/app/shortcuts.test.ts) und nicht ueber eine
+ * gepflegte Liste, denn eine gepflegte Liste ist genau die Stelle, an der ein
+ * Punkt ohne Verdrahtung wieder hereinrutscht.
+ */
+export const WIRED_MENU_SHORTCUTS: readonly string[] = ['a', 'w', 'b', 'l', 'r', 's', 'g', 'p', '?']
+ .filter(key => experimentalAgentsEnabled || key !== 'g');
+
+/**
+ * Das Alphabet, gegen das die beiden Absichtsfunktionen befragt werden.
+ *
+ * Absichtlich grosszuegig: es kostet nichts, und eine Taste, die eine der
+ * Funktionen kuenftig belegt, faellt der Hilfe von selbst zu, statt vergessen
+ * zu werden. Grossbuchstaben stehen nicht darin, weil beide Funktionen sie auf
+ * denselben Sinn abbilden wie den kleinen und die Hilfe die Taste und nicht die
+ * Schreibweise nennt.
+ */
+export const PROBED_KEYS: readonly string[] = [
+ ...'abcdefghijklmnopqrstuvwxyz0123456789',
+ '?', '/', '.', ',', '-', ' ',
+ 'Enter', 'Escape', 'Tab', 'Backspace', 'Delete',
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
+ 'Home', 'End', 'PageUp', 'PageDown',
+];
+
+/** Die Tasten eines Bereichs, erfragt statt aufgeschrieben. */
+function probed(scope: ShortcutScope, meaning: (key: string) => string): AtlasShortcut[] {
+ return PROBED_KEYS.filter((key) => meaning(key) !== 'none').map((key) => ({ scope, key }));
+}
+
+/** Ob diese Taste Alt/Option braucht, um zu gelten. */
+export function needsAlt(shortcut: AtlasShortcut): boolean {
+ return shortcut.scope === 'mnemonic';
+}
+
+/**
+ * Jede Taste, die diese Oberflaeche hoert, in der Reihenfolge, in der die Hilfe
+ * sie zeigt: Menuekuerzel, Galaxy-Suche, dann der Walk.
+ *
+ * Auch die Aufteilung in `mnemonic` und `bare` wird abgelesen und nicht
+ * gepflegt: welche Taste ohne Alt/Option gilt, weiss keyboard.ts, und diese
+ * Liste fragt dort nach.
+ */
+export const ATLAS_SHORTCUTS: readonly AtlasShortcut[] = [
+ ...WIRED_MENU_SHORTCUTS.map((key) => ({
+ scope: (RESERVED_BARE_SHORTCUTS.includes(key) ? 'bare' : 'mnemonic') as ShortcutScope,
+ key,
+ })),
+ { scope: 'galaxy', key: 'Cmd/Ctrl+K' },
+ ...probed('walk', playerIntent),
+];
+
+/** Der Schluessel, unter dem der Katalog den Satz zu dieser Taste fuehrt. */
+export function shortcutId(shortcut: AtlasShortcut): string {
+ return `${shortcut.scope}:${shortcut.key}`;
+}
diff --git a/graph-ui/src/app/tree-model.test.ts b/graph-ui/src/app/tree-model.test.ts
new file mode 100644
index 000000000..80822048f
--- /dev/null
+++ b/graph-ui/src/app/tree-model.test.ts
@@ -0,0 +1,588 @@
+/*
+ * Das Baum-Modell gegen eine woertlich abgeschriebene Antwort von /api/tree.
+ *
+ * Die Nutzlast unten ist das, was der gebaute Server am 2026-08-28 fuer
+ * `GET /api/tree?project=probe-small` geliefert hat, inklusive der beiden
+ * Eigenheiten, die dieses Modul behandelt: der Ordner `src` kommt zweimal
+ * (einmal als "dir", einmal als "file", weil der Folder-Knoten seinen eigenen
+ * Pfad als file_path traegt), und `{}` ist ein Kind, das keine Datei ist.
+ * Eine geglaettete Nutzlast waere hier wertlos: genau diese Zeilen sind der
+ * Grund fuer den Code.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+ aggregateCoverage,
+ buildCoverageIndex,
+ compareChildren,
+ coverageStateForKind,
+ directoryPaths,
+ flattenTree,
+ mergeCoverageIntoLevels,
+ moveCursor,
+ namesAPath,
+ normalizeCoveragePath,
+ parentPath,
+ readCoverageAnswer,
+ readIndexStatusCoverage,
+ readTreeLevel,
+ treeIntent,
+ worstCoverage,
+} from './tree-model';
+import type { TreeLevel, TreeRow } from './tree-model';
+
+/** Woertlich: GET /api/tree?project=probe-small */
+const ROOT_PAYLOAD = {
+ path: '',
+ files: 18,
+ symbols: 71,
+ children: [
+ { name: 'src', path: 'src', kind: 'dir', symbols: 62, files: 13, region: 3 },
+ { name: 'test', path: 'test', kind: 'dir', symbols: 2, files: 1, region: 3 },
+ { name: 'HERKUNFT.md', path: 'HERKUNFT.md', kind: 'file', symbols: 3, files: 1, region: 3 },
+ { name: '{}', path: '{}', kind: 'file', symbols: 2, files: 1, region: 3 },
+ { name: 'src', path: 'src', kind: 'file', symbols: 1, files: 1, region: 3 },
+ { name: 'test', path: 'test', kind: 'file', symbols: 1, files: 1, region: 3 },
+ ],
+};
+
+/** Woertlich: GET /api/tree?project=probe-small&path=src/services */
+const SERVICES_PAYLOAD = {
+ path: 'src/services',
+ files: 2,
+ symbols: 10,
+ children: [
+ { name: 'userService.ts', path: 'src/services/userService.ts', kind: 'file', symbols: 6, files: 1 },
+ { name: 'orderService.ts', path: 'src/services/orderService.ts', kind: 'file', symbols: 4, files: 1 },
+ ],
+};
+
+describe('readTreeLevel', () => {
+
+ const level = readTreeLevel(ROOT_PAYLOAD);
+
+ it('legt den Ordner und seinen Folder-Knoten zu einer Zeile zusammen', () => {
+ const src = level.children.filter((child) => child.path === 'src');
+ expect(src).toHaveLength(1);
+ expect(src[0]?.kind).toBe('dir');
+ expect(level.foldedDuplicates).toBe(2);
+ });
+
+ it('laesst weg, was keinen Pfad benennt, und zaehlt es', () => {
+ expect(level.children.map((child) => child.name)).not.toContain('{}');
+ expect(level.droppedNonPaths).toBe(1);
+ });
+
+ it('haelt die Zahlen des Servers fest', () => {
+ expect(level.files).toBe(18);
+ expect(level.symbols).toBe(71);
+ expect(level.children.find((child) => child.path === 'src')?.symbols).toBe(62);
+ });
+
+ it('sortiert Ordner vor Dateien, beides alphabetisch', () => {
+ expect(level.children.map((child) => child.path)).toEqual(['src', 'test', 'HERKUNFT.md']);
+ });
+
+ it('vertraegt eine Antwort, die gar keine ist', () => {
+ const empty = readTreeLevel(undefined);
+ expect(empty.children).toEqual([]);
+ expect(empty.files).toBe(0);
+ });
+
+ it('nimmt missed nur mit, wenn der Server es sagt', () => {
+ const withMissed = readTreeLevel({
+ path: '',
+ children: [{ name: 'a', path: 'a', kind: 'dir', symbols: 1, files: 1, missed: 2 }],
+ });
+ expect(withMissed.children[0]?.missed).toBe(2);
+ expect(level.children[0]?.missed).toBeUndefined();
+ });
+});
+
+describe('namesAPath', () => {
+
+ it('kennt genau die eine Form, die kein Pfad ist', () => {
+ expect(namesAPath({ name: '{}', path: '{}' })).toBe(false);
+ expect(namesAPath({ name: '', path: '' })).toBe(false);
+ // Alles andere bleibt: eine verschluckte Datei waere schlimmer als eine
+ // haessliche Zeile.
+ expect(namesAPath({ name: 'weird name.ts', path: 'weird name.ts' })).toBe(true);
+ });
+});
+
+describe('compareChildren', () => {
+
+ it('stellt Ordner vor Dateien', () => {
+ const dir = { name: 'z', path: 'z', kind: 'dir' as const, symbols: 0, files: 0 };
+ const file = { name: 'a', path: 'a', kind: 'file' as const, symbols: 0, files: 0 };
+ expect(compareChildren(dir, file)).toBeLessThan(0);
+ });
+});
+
+describe('flattenTree', () => {
+
+ const levels = new Map([
+ ['', readTreeLevel(ROOT_PAYLOAD)],
+ ['src/services', readTreeLevel(SERVICES_PAYLOAD)],
+ ]);
+
+ it('zeigt ohne aufgeklappte Ordner nur die oberste Ebene', () => {
+ const rows = flattenTree(levels, new Set());
+ expect(rows.map((row) => row.path)).toEqual(['src', 'test', 'HERKUNFT.md']);
+ expect(rows.every((row) => row.depth === 0)).toBe(true);
+ });
+
+ it('haengt eine geladene Ebene unter ihren aufgeklappten Ordner', () => {
+ const withServices = new Map(levels);
+ withServices.set('src', readTreeLevel({
+ path: 'src',
+ children: [{ name: 'services', path: 'src/services', kind: 'dir', symbols: 10, files: 2 }],
+ }));
+ const rows = flattenTree(withServices, new Set(['src', 'src/services']));
+ expect(rows.map((row) => row.path)).toEqual([
+ 'src',
+ 'src/services',
+ 'src/services/orderService.ts',
+ 'src/services/userService.ts',
+ 'test',
+ 'HERKUNFT.md',
+ ]);
+ expect(rows.find((row) => row.path === 'src/services/userService.ts')?.depth).toBe(2);
+ });
+
+ it('erzeugt fuer einen aufgeklappten, aber ungeladenen Ordner keine Zeilen', () => {
+ const rows = flattenTree(levels, new Set(['test']));
+ expect(rows.map((row) => row.path)).toEqual(['src', 'test', 'HERKUNFT.md']);
+ expect(rows.find((row) => row.path === 'test')?.loaded).toBe(false);
+ expect(rows.find((row) => row.path === 'test')?.expanded).toBe(true);
+ });
+});
+
+describe('directoryPaths', () => {
+
+ it('nennt die Ordner einer Ebene in Anzeigeordnung', () => {
+ expect(directoryPaths(readTreeLevel(ROOT_PAYLOAD))).toEqual(['src', 'test']);
+ });
+});
+
+describe('parentPath', () => {
+
+ it('geht eine Ebene hoch und endet an der Wurzel', () => {
+ expect(parentPath('src/services/userService.ts')).toBe('src/services');
+ expect(parentPath('src')).toBe('');
+ });
+});
+
+const row = (kind: 'dir' | 'file', expanded = false): TreeRow => ({
+ name: 'x',
+ path: 'x',
+ kind,
+ symbols: 0,
+ files: 0,
+ depth: 0,
+ expanded,
+ loaded: true,
+});
+
+describe('treeIntent', () => {
+
+ it('oeffnet eine Datei mit Enter', () => {
+ expect(treeIntent('Enter', row('file'))).toBe('open');
+ });
+
+ it('klappt einen Ordner mit Enter auf und wieder zu', () => {
+ expect(treeIntent('Enter', row('dir'))).toBe('expand');
+ expect(treeIntent('Enter', row('dir', true))).toBe('collapse');
+ });
+
+ it('geht mit Rechts in einen aufgeklappten Ordner hinein', () => {
+ expect(treeIntent('ArrowRight', row('dir'))).toBe('expand');
+ expect(treeIntent('ArrowRight', row('dir', true))).toBe('down');
+ expect(treeIntent('ArrowRight', row('file'))).toBe('none');
+ });
+
+ it('klappt mit Links zu, sonst geht es zum Elternordner', () => {
+ expect(treeIntent('ArrowLeft', row('dir', true))).toBe('collapse');
+ expect(treeIntent('ArrowLeft', row('file'))).toBe('toParent');
+ });
+
+ it('laesst jede andere Taste durch', () => {
+ expect(treeIntent('a', row('file'))).toBe('none');
+ expect(treeIntent('Enter', undefined)).toBe('none');
+ });
+});
+
+describe('moveCursor', () => {
+
+ it('haelt an den Enden fest, statt umzubrechen', () => {
+ expect(moveCursor(3, 0, -1)).toBe(0);
+ expect(moveCursor(3, 2, 1)).toBe(2);
+ expect(moveCursor(3, 1, 1)).toBe(2);
+ expect(moveCursor(0, 0, 1)).toBe(0);
+ });
+});
+
+/*
+ * Der Coverage-Join.
+ *
+ * Die beiden Nutzlasten unten sind die Formen, die der gebaute Server aus
+ * PR 1860 schreibt (mcp.c, add_coverage_report und handle_check_index_coverage).
+ * Sie stehen hier vollstaendig, inklusive der Felder, die dieses Projekt nicht
+ * liest: eine gekuerzte Nutzlast waere ein Test gegen die eigene Erwartung
+ * statt gegen die Antwort.
+ */
+
+/** Die Coverage-Beilage einer index_status-Antwort. */
+const STATUS_PAYLOAD = {
+ project: 'probe-small',
+ nodes: 71,
+ edges: 132,
+ status: 'ready',
+ root_path: '/tmp/probe-small',
+ parse_partial: {
+ files: [{ path: 'src/broken.ts', error_ranges: '12-18,24' }],
+ count: 1,
+ truncated: false,
+ },
+ skipped: {
+ files: [{ path: 'assets/beleg.png', reason: 'unsupported extension', phase: 'discovery' }],
+ count: 1,
+ truncated: false,
+ },
+ not_indexed: {
+ dirs: ['node_modules'],
+ dirs_count: 1,
+ files: [{ path: 'secret.env', reason: '.gitignore' }],
+ files_count: 1,
+ truncated: false,
+ note: 'Purposely not indexed - excluded BY DESIGN via gitignore/.cbmignore/skip-lists.',
+ },
+ coverage_note: 'Best-effort signal, not a completeness guarantee.',
+};
+
+/** Eine Scope-Antwort von check_index_coverage, Wurzel-Scope. */
+const SCOPE_PAYLOAD = {
+ project: 'probe-small',
+ signal: 'best_effort',
+ indexed_at: '2026-08-28T20:00:00Z',
+ metadata: {
+ generation: '2026-08-28T20:00:00Z',
+ index_mode: 'full',
+ recorded_at: '2026-08-28T20:00:01Z',
+ recording_status: 'complete',
+ ignored_files_stored: 0,
+ ignored_files_total: 4,
+ hash_records_complete: true,
+ coverage_version: 2,
+ generation_matches: true,
+ },
+ paths: [],
+ scopes: [
+ {
+ requested_scope: '.',
+ scope: '.',
+ total: 3,
+ has_more: false,
+ entries: [
+ { path: 'src/broken.ts', kind: 'parse_partial', detail: '12-18,24' },
+ { path: 'assets/beleg.png', kind: 'discovery', detail: 'unsupported extension' },
+ { path: 'src/late.ts', kind: 'not_indexed_file', detail: '.cbmignore' },
+ ],
+ status: 'known_gaps',
+ },
+ ],
+ caveat: 'Best-effort signal only.',
+};
+
+describe('normalizeCoveragePath', () => {
+
+ it('macht aus den Schreibweisen beider Quellen einen Pfad', () => {
+ expect(normalizeCoveragePath('./src/a.ts')).toBe('src/a.ts');
+ expect(normalizeCoveragePath('/src/a.ts')).toBe('src/a.ts');
+ expect(normalizeCoveragePath('src\\a.ts')).toBe('src/a.ts');
+ expect(normalizeCoveragePath('src/')).toBe('src');
+ });
+});
+
+describe('coverageStateForKind', () => {
+
+ it('kennt genau die Namen, die der Server schreibt', () => {
+ expect(coverageStateForKind('parse_partial')).toBe('partial');
+ expect(coverageStateForKind('not_indexed_file')).toBe('not-indexed');
+ expect(coverageStateForKind('not_indexed_dir')).toBe('not-indexed');
+ expect(coverageStateForKind('ignored_file')).toBe('ignored');
+ });
+
+ it('nimmt jede andere Phase als uebersprungen, so wie der Server es tut', () => {
+ expect(coverageStateForKind('discovery')).toBe('skipped');
+ expect(coverageStateForKind('parse_failed')).toBe('skipped');
+ });
+});
+
+describe('worstCoverage', () => {
+
+ it('stellt eine unerklaerte Luecke ueber eine erklaerte und beide ueber partiell', () => {
+ expect(worstCoverage('indexed', 'partial')).toBe('partial');
+ expect(worstCoverage('partial', 'not-indexed')).toBe('not-indexed');
+ expect(worstCoverage('not-indexed', 'skipped')).toBe('skipped');
+ expect(worstCoverage('skipped', 'partial')).toBe('skipped');
+ });
+});
+
+describe('readIndexStatusCoverage', () => {
+
+ const status = readIndexStatusCoverage(STATUS_PAYLOAD);
+
+ it('liest die drei Listen mit Zahlen und Kappungsflagge', () => {
+ expect(status.parsePartial).toEqual([{ path: 'src/broken.ts', errorRanges: '12-18,24' }]);
+ expect(status.skipped[0]?.reason).toBe('unsupported extension');
+ expect(status.notIndexedDirs).toEqual(['node_modules']);
+ expect(status.notIndexedFiles[0]?.path).toBe('secret.env');
+ expect(status.parsePartialCount).toBe(1);
+ expect(status.notIndexedTruncated).toBe(false);
+ });
+
+ it('macht aus einer Antwort ohne Beilage leere Listen und keine Behauptung', () => {
+ const empty = readIndexStatusCoverage({ project: 'x', nodes: 1 });
+ expect(empty.parsePartial).toEqual([]);
+ expect(empty.skippedCount).toBe(0);
+ expect(empty.notIndexedDirs).toEqual([]);
+ });
+});
+
+describe('readCoverageAnswer', () => {
+
+ const answer = readCoverageAnswer(SCOPE_PAYLOAD);
+
+ it('preserves structured parser ranges from the real coverage RPC in the shared evidence', () => {
+ const parsed = readCoverageAnswer({ scopes: [{ scope: '.', entries: [
+ { path: 'partial.c', kind: 'parse_partial', ranges: [{ start: 2, end: 2 }, { start: 5, end: 8 }, { start: 0, end: 4 }, { start: 7, end: 3 }, { start: '1', end: 2 }] },
+ { path: 'explicit.c', kind: 'parse_partial', detail: 'original detail', ranges: [{ start: 1, end: 2 }] },
+ { path: 'unknown.c', kind: 'parse_partial', ranges: [{ start: -1, end: 2 }] },
+ ] }] });
+ const index = buildCoverageIndex({ scopes: parsed.scopes });
+ expect(index.records.get('partial.c')?.reason).toBe('Source lines: 2-2, 5-8');
+ expect(index.records.get('explicit.c')?.reason).toBe('original detail');
+ expect(index.records.get('unknown.c')?.reason).toBe('');
+ });
+
+ it('liest den Scope samt Seitenansage', () => {
+ expect(answer.scopes[0]?.scope).toBe('.');
+ expect(answer.scopes[0]?.total).toBe(3);
+ expect(answer.scopes[0]?.hasMore).toBe(false);
+ expect(answer.scopes[0]?.entries).toHaveLength(3);
+ });
+
+ it('haelt die Metadaten fest, inklusive der ignored-Zahlen', () => {
+ expect(answer.metadata.recordingStatus).toBe('complete');
+ expect(answer.metadata.ignoredFilesStored).toBe(0);
+ expect(answer.metadata.ignoredFilesTotal).toBe(4);
+ expect(answer.metadata.generationMatches).toBe(true);
+ });
+
+ it('liest eine Pfad-Antwort mit Frische und Empfehlung', () => {
+ const single = readCoverageAnswer({
+ project: 'probe-small',
+ metadata: {},
+ paths: [
+ {
+ requested_path: 'src/a.ts',
+ path: 'src/a.ts',
+ status: 'no_recorded_issue',
+ freshness: 'metadata_changed',
+ recommended_action: 'read_source_and_reindex',
+ coverage: [],
+ },
+ ],
+ scopes: [],
+ });
+ expect(single.paths[0]?.freshness).toBe('metadata_changed');
+ expect(single.paths[0]?.recommendedAction).toBe('read_source_and_reindex');
+ });
+});
+
+describe('buildCoverageIndex', () => {
+
+ it('keeps unresolved status omissions when pages repeat rows, change totals, or cover only a subtree', () => {
+ const status = readIndexStatusCoverage({ parse_partial: { files: [], count: 2, truncated: true } });
+ const page = { requestedScope: '.', scope: '.', status: 'known_gaps', total: 2, hasMore: true, entries: [{ path: 'partial.c', kind: 'parse_partial', detail: '1-2' }] };
+ const repeated = buildCoverageIndex({ status, scopes: [page, { ...page, hasMore: false }] });
+ expect(repeated.listingComplete).toBe(false);
+ expect(repeated.truncations.some((line) => line.includes('cut the parse_partial'))).toBe(true);
+ expect(repeated.truncations.some((line) => line.includes('1 distinct rows loaded'))).toBe(true);
+ const changed = buildCoverageIndex({ scopes: [page, { ...page, total: 1, hasMore: false }] });
+ expect(changed.listingComplete).toBe(false);
+ const subtree = buildCoverageIndex({ status, scopes: [{ ...page, scope: 'src', total: 1, hasMore: false }] });
+ expect(subtree.listingComplete).toBe(false);
+ expect(subtree.truncations.some((line) => line.includes('cut the parse_partial'))).toBe(true);
+ });
+
+ it('does not call unavailable coverage complete even when its empty count agrees', () => {
+ const index = buildCoverageIndex({ scopes: [{ requestedScope: '.', scope: '.', status: 'coverage_unavailable', total: 0, hasMore: false, entries: [] }] });
+ expect(index.listingComplete).toBe(false);
+ expect(index.truncations).toHaveLength(1);
+ });
+
+ const index = buildCoverageIndex({
+ status: readIndexStatusCoverage(STATUS_PAYLOAD),
+ scopes: readCoverageAnswer(SCOPE_PAYLOAD).scopes,
+ });
+
+ it('legt jeden Pfad genau einmal ab und nennt seine Quellen', () => {
+ const broken = index.records.get('src/broken.ts');
+ expect(broken?.state).toBe('partial');
+ expect(broken?.sources).toEqual([
+ 'index_status.parse_partial',
+ 'check_index_coverage.scope:.',
+ ]);
+ });
+
+ it('nimmt einen Pfad auf, den nur die Scope-Liste kennt', () => {
+ expect(index.records.get('src/late.ts')?.state).toBe('not-indexed');
+ expect(index.records.get('src/late.ts')?.reason).toBe('.cbmignore');
+ });
+
+ it('unterscheidet Ordner von Dateien', () => {
+ expect(index.records.get('node_modules')?.kind).toBe('dir');
+ expect(index.records.get('secret.env')?.kind).toBe('file');
+ });
+
+ it('zaehlt, was der Server gezaehlt hat, und nicht, was ankam', () => {
+ expect(index.counts.partial).toBe(1);
+ expect(index.counts.skipped).toBe(1);
+ expect(index.counts.notIndexedDirs).toBe(1);
+ expect(index.counts.notIndexedFiles).toBe(1);
+ expect(index.counts.scopeEntries).toBe(3);
+ });
+
+ it('macht aus einer gekappten Liste eine ehrliche Zeile', () => {
+ const cut = buildCoverageIndex({
+ status: readIndexStatusCoverage({
+ ...STATUS_PAYLOAD,
+ skipped: { files: [], count: 812, truncated: true },
+ }),
+ });
+ expect(cut.truncations.some((line) => /cut the skipped list/.test(line))).toBe(true);
+ expect(cut.truncations.some((line) => /812/.test(line))).toBe(true);
+ });
+
+ it('meldet eine Scope-Seite, die nicht zu Ende geholt wurde', () => {
+ const more = buildCoverageIndex({
+ scopes: readCoverageAnswer({
+ ...SCOPE_PAYLOAD,
+ scopes: [{ ...SCOPE_PAYLOAD.scopes[0], has_more: true, total: 4000 }],
+ }).scopes,
+ });
+ expect(more.truncations.some((line) => /4000 recorded/.test(line))).toBe(true);
+ });
+
+ it('sagt bei gar keiner Quelle nichts, statt Vollstaendigkeit zu behaupten', () => {
+ const nothing = buildCoverageIndex({});
+ expect(nothing.records.size).toBe(0);
+ expect(nothing.truncations).toEqual([]);
+ });
+});
+
+describe('aggregateCoverage', () => {
+
+ const index = buildCoverageIndex({ status: readIndexStatusCoverage(STATUS_PAYLOAD) });
+
+ it('gibt einem Ordner die schlechteste Stufe seines Teilbaums', () => {
+ // assets/beleg.png ist skipped, src/broken.ts nur partial.
+ expect(aggregateCoverage(index, 'assets')).toBe('skipped');
+ expect(aggregateCoverage(index, 'src')).toBe('partial');
+ });
+
+ it('nimmt den eigenen Befund eines Ordners mit', () => {
+ expect(aggregateCoverage(index, 'node_modules')).toBe('not-indexed');
+ });
+
+ it('laesst einen sauberen Ordner sauber', () => {
+ expect(aggregateCoverage(index, 'test')).toBe('indexed');
+ });
+
+ it('verwechselt keinen Praefix mit einem Ordner', () => {
+ // "src" darf nicht auf "srcfoo/x.ts" passen.
+ const tricky = buildCoverageIndex({
+ status: readIndexStatusCoverage({
+ skipped: { files: [{ path: 'srcfoo/x.ts', reason: 'r', phase: 'p' }], count: 1 },
+ }),
+ });
+ expect(aggregateCoverage(tricky, 'src')).toBe('indexed');
+ expect(aggregateCoverage(tricky, 'srcfoo')).toBe('skipped');
+ });
+});
+
+describe('mergeCoverageIntoLevels', () => {
+
+ const graphLevels = new Map([
+ ['', readTreeLevel({
+ path: '',
+ files: 3,
+ symbols: 12,
+ children: [
+ { name: 'src', path: 'src', kind: 'dir', symbols: 12, files: 3 },
+ { name: 'test', path: 'test', kind: 'dir', symbols: 2, files: 1 },
+ ],
+ })],
+ ['src', readTreeLevel({
+ path: 'src',
+ children: [
+ { name: 'broken.ts', path: 'src/broken.ts', kind: 'file', symbols: 2, files: 1 },
+ { name: 'clean.ts', path: 'src/clean.ts', kind: 'file', symbols: 5, files: 1 },
+ ],
+ })],
+ ]);
+ const index = buildCoverageIndex({
+ status: readIndexStatusCoverage(STATUS_PAYLOAD),
+ scopes: readCoverageAnswer(SCOPE_PAYLOAD).scopes,
+ });
+ const merged = mergeCoverageIntoLevels(graphLevels, index);
+
+ it('laesst partial gegen den Graphen gewinnen', () => {
+ // src/broken.ts steht im Graphen UND in parse_partial. Der Konflikt
+ // wird zugunsten des Problems entschieden.
+ const child = merged.get('src')?.children.find((entry) => entry.path === 'src/broken.ts');
+ expect(child?.coverage).toBe('partial');
+ expect(child?.coverageReason).toBe('12-18,24');
+ });
+
+ it('laesst eine Datei ohne Befund indexed', () => {
+ expect(merged.get('src')?.children.find((entry) => entry.path === 'src/clean.ts')?.coverage)
+ .toBe('indexed');
+ });
+
+ it('haengt Pfade ein, die der Graph nicht kennt, samt ihrer Ordner', () => {
+ const root = merged.get('')?.children.map((child) => child.path) ?? [];
+ expect(root).toContain('assets');
+ expect(root).toContain('node_modules');
+ expect(root).toContain('secret.env');
+ expect(merged.get('assets')?.children.map((child) => child.path))
+ .toEqual(['assets/beleg.png']);
+ expect(merged.get('assets')?.synthetic).toBe(true);
+ });
+
+ it('haelt eine Ebene aus dem Graphen als nicht synthetisch fest', () => {
+ expect(merged.get('src')?.synthetic).toBeUndefined();
+ });
+
+ it('gibt Ordnern die schlechteste Stufe ihrer Kinder', () => {
+ const root = merged.get('') ?? { children: [] };
+ const of = (path: string) => root.children.find((child) => child.path === path)?.coverage;
+ expect(of('assets')).toBe('skipped');
+ expect(of('src')).toBe('not-indexed');
+ expect(of('node_modules')).toBe('not-indexed');
+ expect(of('test')).toBe('indexed');
+ });
+
+ it('laesst die Antworten des Servers unberuehrt', () => {
+ expect(graphLevels.get('src')?.children[0]?.coverage).toBeUndefined();
+ });
+
+ it('zeigt jeden eingehaengten Pfad auch wirklich als Zeile', () => {
+ const expanded = new Set(['src', 'assets', 'node_modules']);
+ const paths = flattenTree(merged, expanded).map((row) => row.path);
+ for (const record of index.records.values()) {
+ expect(paths).toContain(record.path);
+ }
+ });
+});
diff --git a/graph-ui/src/app/tree-model.ts b/graph-ui/src/app/tree-model.ts
new file mode 100644
index 000000000..53ee8f9f8
--- /dev/null
+++ b/graph-ui/src/app/tree-model.ts
@@ -0,0 +1,967 @@
+/**
+ * Der Datei-Baum, wie ihn der Explorer zeigt: die VEREINIGUNG aus dem Graphen
+ * und den Coverage-Listen des Servers.
+ *
+ * `/api/tree?project=X&path=Y` antwortet mit genau einer Ebene: den direkten
+ * Kindern von Y, jedes mit `name`, `path`, `kind` ("dir" oder "file") und den
+ * Zahlen `symbols` und `files`. Der Baum kommt also stueckweise, eine Ebene je
+ * Anfrage, und dieses Modul haelt die Ebenen zusammen und rechnet aus, welche
+ * Zeilen davon gerade sichtbar sind.
+ *
+ * **Der Graph ist nur eine der Quellen.** Bis W4c war er die einzige, und damit
+ * zeigte der Explorer genau die Dateien, aus denen Symbole geworden sind. Eine
+ * Datei, die der Indexer uebersprungen hat, war unsichtbar, und Unsichtbarkeit
+ * ist in einem Explorer die Behauptung, es gebe sie nicht. Seit W4d stehen
+ * daneben zwei weitere Quellen, beide vom selben Server:
+ *
+ * - `index_status` traegt eine Coverage-Beilage: `parse_partial` (indiziert,
+ * aber mit Parse-Fehlern), `skipped` (gar nicht indiziert, mit Grund) und
+ * `not_indexed` (nach Absicht ausgeschlossen, Dateien UND Verzeichnisse).
+ * Jede der drei Listen nennt ihre eigene Zahl und ihr eigenes `truncated`.
+ * - `check_index_coverage` mit `scopes: ["."]` listet die Eintraege des
+ * Coverage-Stores selbst, paginiert ueber `scope_offset`, und deckt damit
+ * auch Faelle, die `index_status` kappt.
+ *
+ * Der Coverage-Store ist eine eigene Tabelle NEBEN dem Graphen (mcp.c: "coverage
+ * is metadata ABOUT the graph, stored outside it"). Darum ist die Vereinigung
+ * keine Bequemlichkeit, sondern die einzige Form, in der der Explorer beide
+ * Wahrheiten gleichzeitig zeigen kann: was indiziert wurde, und was der Index
+ * angefasst und liegengelassen hat.
+ *
+ * Was danach immer noch fehlt, wird nicht verschwiegen: eine Datei, die die
+ * Discovery nie gesehen hat, steht in keiner der drei Quellen. Die Legende im
+ * Explorer-Fuss sagt das mit eigenen Worten, statt den Baum vollstaendig
+ * aussehen zu lassen.
+ *
+ * Zwei Eigenheiten des Graph-Baums bleiben, an einem laufenden Server
+ * abgelesen und hier ausdruecklich behandelt, statt sie im Explorer als
+ * Geisterzeilen zu zeigen:
+ *
+ * 1. **Ordner erscheinen doppelt.** Der Server gruppiert alle Knoten nach
+ * `file_path`. Ein Folder-Knoten traegt als `file_path` den Ordnerpfad
+ * selbst, also faellt zu `src` ein zweites Kind an, diesmal mit
+ * `kind: "file"`. Zwei Kinder mit demselben Pfad sind aber ein Ding: der
+ * Ordner. Sie werden zusammengelegt, der Ordner gewinnt, und wie oft das
+ * passiert ist, steht in `foldedDuplicates`.
+ * 2. **`{}` ist kein Pfad.** Die Project- und Branch-Knoten des Projekts
+ * tragen `{}` als `file_path`, und der Server macht daraus brav ein Kind
+ * namens `{}`. Eine Datei dieses Namens gibt es nicht. Solche Kinder fallen
+ * weg und werden in `droppedNonPaths` gezaehlt, damit der Explorer sagen
+ * kann, dass er etwas weggelassen hat, statt es zu verschweigen.
+ *
+ * Beides ist ein Serverbefund und gehoert auf die Ask-Liste, nicht in eine
+ * stille Reparatur: gezaehlt wird, was wegfaellt.
+ */
+
+/**
+ * Wie weit der Index eine Datei erfasst hat.
+ *
+ * Die fuenf Werte sind genau die Klassen, die der Server meldet, und keine
+ * mehr: `indexed` ist die Abwesenheit eines Befundes, die anderen vier kommen
+ * aus dem `kind` einer Coverage-Zeile. Ein sechster Wert waere ein Status, den
+ * niemand gemeldet hat.
+ */
+export type CoverageState = 'indexed' | 'partial' | 'skipped' | 'not-indexed' | 'ignored';
+
+/**
+ * Die Stufen von "ganz da" nach "am unerklaertesten weg".
+ *
+ * Die Reihenfolge ist eine Aussage und keine Geschmacksfrage. `partial` steht
+ * vorn, weil die Datei im Graphen steht und nur Teile fehlen. Danach kommen die
+ * beiden erklaerten Abwesenheiten: `ignored` (der Coverage-Store fuehrt sie als
+ * ignoriert) und `not-indexed` (nach Absicht ausgeschlossen, mit Regel als
+ * Grund). Ganz hinten steht `skipped`, denn dort hat der Indexer die Datei
+ * angefasst und aufgegeben: eine unerklaerte Luecke ist fuer einen Leser
+ * schlimmer als eine erklaerte.
+ */
+export const COVERAGE_ORDER: readonly CoverageState[] = [
+ 'indexed',
+ 'partial',
+ 'ignored',
+ 'not-indexed',
+ 'skipped',
+];
+
+/** Der Rang einer Stufe. Groesser heisst schlechter erfasst. */
+export function coverageRank(state: CoverageState): number {
+ const index = COVERAGE_ORDER.indexOf(state);
+ return index === -1 ? 0 : index;
+}
+
+/** Die schlechtere der beiden Stufen. Der Konfliktloeser des Joins. */
+export function worstCoverage(left: CoverageState, right: CoverageState): CoverageState {
+ return coverageRank(right) > coverageRank(left) ? right : left;
+}
+
+/**
+ * Das `kind` einer Coverage-Zeile als Stufe.
+ *
+ * Der Server schreibt `parse_partial`, `not_indexed_dir`, `not_indexed_file`
+ * und ansonsten den Namen der Phase, in der er aufgegeben hat (mcp.c,
+ * add_coverage_report: alles, was keiner der drei Namen ist, landet unter
+ * `skipped`). Genau diese Zuordnung steht hier, damit die Oberflaeche keine
+ * eigene erfindet.
+ */
+export function coverageStateForKind(kind: string): CoverageState {
+ if (kind === 'parse_partial') {
+ return 'partial';
+ }
+ if (kind.startsWith('not_indexed')) {
+ return 'not-indexed';
+ }
+ if (kind.startsWith('ignored') || kind.startsWith('gitignored')) {
+ return 'ignored';
+ }
+ return 'skipped';
+}
+
+/** Ein Kind einer Baum-Ebene, so wie der Explorer es zeigt. */
+export interface TreeChild {
+ name: string;
+ path: string;
+ kind: 'dir' | 'file';
+ /** Symbole, die der Graph unter diesem Kind fuehrt. */
+ symbols: number;
+ /** Dateien, die der Graph unter diesem Kind fuehrt. */
+ files: number;
+ /** Dateien, die der Index unter diesem Kind ausgelassen hat, wenn er es sagt. */
+ missed?: number;
+ /**
+ * Die Stufe dieser Zeile. Bei Ordnern die schlechteste ihres Teilbaums.
+ *
+ * Fehlt sie, ist noch nichts gejoint worden. Das ist ausdruecklich nicht
+ * dasselbe wie `indexed`: solange die Coverage-Antwort nicht da ist, hat
+ * niemand etwas ueber diese Datei gesagt.
+ */
+ coverage?: CoverageState;
+ /** Der Grund des Servers, soweit er einen genannt hat. */
+ coverageReason?: string;
+ /** Die Quellen, die diesen Pfad genannt haben. Fuer die ehrliche Herkunft. */
+ coverageSources?: string[];
+}
+
+/** Eine Ebene des Baums, plus was auf dem Weg dorthin weggefallen ist. */
+export interface TreeLevel {
+ path: string;
+ files: number;
+ symbols: number;
+ children: TreeChild[];
+ /** Wie viele Kinder mit einem Ordner desselben Pfades zusammengelegt wurden. */
+ foldedDuplicates: number;
+ /** Wie viele Kinder keinen Pfad benannt haben und darum weggefallen sind. */
+ droppedNonPaths: number;
+ /**
+ * True, wenn diese Ebene nicht von `/api/tree` kam, sondern beim Join
+ * entstanden ist, weil ein Coverage-Eintrag darunter liegt.
+ *
+ * Der Explorer braucht das, um ein fehlgeschlagenes Nachladen einer solchen
+ * Ebene nicht als Baumfehler zu melden: ein Ordner, den nur die
+ * Coverage-Listen kennen, hat im Graphen keine Ebene, und das ist der
+ * erwartete Fall und kein Ausfall.
+ */
+ synthetic?: boolean;
+}
+
+/** Eine sichtbare Zeile des Explorers. */
+export interface TreeRow extends TreeChild {
+ /** Verschachtelungstiefe, 0 fuer die oberste Ebene. */
+ depth: number;
+ /** Nur fuer Ordner: ob die Ebene darunter gerade gezeigt wird. */
+ expanded: boolean;
+ /** Nur fuer Ordner: ob die Ebene darunter schon geladen ist. */
+ loaded: boolean;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function asNumber(value: unknown): number {
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+/**
+ * Ob ein Kind einen Pfad benennt.
+ *
+ * Die Pruefung ist absichtlich schmal: sie kennt genau die eine Form, die der
+ * Server statt eines Pfades schickt (`{}`, das leere JSON-Objekt der
+ * Project- und Branch-Knoten), und sonst nichts. Eine breitere Heuristik wuerde
+ * frueher oder spaeter eine echte Datei verschlucken, und eine verschluckte
+ * Datei ist schlimmer als eine haessliche Zeile.
+ */
+export function namesAPath(child: { name: string; path: string }): boolean {
+ if (child.name.length === 0 || child.path.length === 0) {
+ return false;
+ }
+ return child.name !== '{}' && child.path !== '{}';
+}
+
+/**
+ * Anzeigeordnung: Ordner zuerst, dann Dateien, beides alphabetisch.
+ *
+ * Der Server sortiert nach Symbolzahl. Das ist eine sinnvolle Ordnung fuer eine
+ * Auswertung und eine schlechte fuer einen Explorer, in dem man eine Datei
+ * *suchen* will. Die Ordnung hier ist stabil und haengt an nichts, was sich
+ * beim naechsten Indexlauf verschiebt.
+ */
+export function compareChildren(left: TreeChild, right: TreeChild): number {
+ if (left.kind !== right.kind) {
+ return left.kind === 'dir' ? -1 : 1;
+ }
+ return left.name.localeCompare(right.name, 'en');
+}
+
+/** Eine Antwort von `/api/tree` lesen: falten, aussortieren, sortieren. */
+export function readTreeLevel(payload: unknown): TreeLevel {
+ const raw = isRecord(payload) ? payload : {};
+ const rawChildren = Array.isArray(raw['children']) ? raw['children'] : [];
+
+ const byPath = new Map();
+ let foldedDuplicates = 0;
+ let droppedNonPaths = 0;
+
+ for (const entry of rawChildren) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const name = typeof entry['name'] === 'string' ? entry['name'] : '';
+ const path = typeof entry['path'] === 'string' ? entry['path'] : '';
+ if (!namesAPath({ name, path })) {
+ droppedNonPaths += 1;
+ continue;
+ }
+ const child: TreeChild = {
+ name,
+ path,
+ kind: entry['kind'] === 'dir' ? 'dir' : 'file',
+ symbols: asNumber(entry['symbols']),
+ files: asNumber(entry['files']),
+ };
+ const missed = asNumber(entry['missed']);
+ if (missed > 0) {
+ child.missed = missed;
+ }
+
+ const existing = byPath.get(path);
+ if (existing === undefined) {
+ byPath.set(path, child);
+ continue;
+ }
+ // Derselbe Pfad zweimal: der Ordner ist die Wahrheit, die Zahlen des
+ // Ordners auch. Der Folder-Knoten steuert nur sich selbst bei.
+ foldedDuplicates += 1;
+ if (existing.kind === 'file' && child.kind === 'dir') {
+ byPath.set(path, child);
+ }
+ }
+
+ return {
+ path: typeof raw['path'] === 'string' ? raw['path'] : '',
+ files: asNumber(raw['files']),
+ symbols: asNumber(raw['symbols']),
+ children: [...byPath.values()].sort(compareChildren),
+ foldedDuplicates,
+ droppedNonPaths,
+ };
+}
+
+/** Der Pfad des Ordners ueber diesem Pfad. Leer heisst Projektwurzel. */
+export function parentPath(path: string): string {
+ const slash = path.lastIndexOf('/');
+ return slash === -1 ? '' : path.slice(0, slash);
+}
+
+/**
+ * Die sichtbaren Zeilen: die Wurzel-Ebene, und unter jedem aufgeklappten
+ * Ordner dessen geladene Ebene.
+ *
+ * Ein aufgeklappter Ordner ohne geladene Ebene erzeugt keine Zeilen und keinen
+ * Platzhalter. Er traegt `loaded: false`, und der Explorer zeigt das am
+ * Pfeil-Zeichen, statt eine leere Ebene als "hier ist nichts" auszugeben.
+ */
+export function flattenTree(
+ levels: ReadonlyMap,
+ expanded: ReadonlySet,
+ rootPath = '',
+): TreeRow[] {
+ const rows: TreeRow[] = [];
+ const walk = (path: string, depth: number, seen: Set): void => {
+ const level = levels.get(path);
+ if (level === undefined || seen.has(path)) {
+ return;
+ }
+ seen.add(path);
+ for (const child of level.children) {
+ const isExpanded = child.kind === 'dir' && expanded.has(child.path);
+ rows.push({
+ ...child,
+ depth,
+ expanded: isExpanded,
+ loaded: child.kind === 'dir' ? levels.has(child.path) : true,
+ });
+ if (isExpanded) {
+ walk(child.path, depth + 1, seen);
+ }
+ }
+ };
+ walk(rootPath, 0, new Set());
+ return rows;
+}
+
+/** Alle Ordnerpfade einer Ebene, in Anzeigeordnung. */
+export function directoryPaths(level: TreeLevel): string[] {
+ return level.children.filter((child) => child.kind === 'dir').map((child) => child.path);
+}
+
+/** Was eine Taste im Explorer bedeutet. */
+export type TreeIntent = 'up' | 'down' | 'open' | 'expand' | 'collapse' | 'toParent' | 'none';
+
+/**
+ * Die Taste, uebersetzt in eine Absicht, mit Blick auf die Zeile unter dem
+ * Cursor.
+ *
+ * Rechts auf einem zugeklappten Ordner klappt auf, auf einem aufgeklappten geht
+ * es in die erste Zeile darunter, also einfach nach unten. Links auf einem
+ * aufgeklappten Ordner klappt zu, sonst springt es zum Elternordner. Das ist
+ * das Verhalten, das jeder Baum in jeder IDE hat, und es steht hier als reine
+ * Funktion, damit es ohne DOM pruefbar ist.
+ */
+export function treeIntent(key: string, row: TreeRow | undefined): TreeIntent {
+ switch (key) {
+ case 'ArrowDown':
+ return 'down';
+ case 'ArrowUp':
+ return 'up';
+ case 'ArrowRight':
+ if (row === undefined || row.kind !== 'dir') {
+ return 'none';
+ }
+ return row.expanded ? 'down' : 'expand';
+ case 'ArrowLeft':
+ if (row !== undefined && row.kind === 'dir' && row.expanded) {
+ return 'collapse';
+ }
+ return 'toParent';
+ case 'Enter':
+ case ' ':
+ if (row === undefined) {
+ return 'none';
+ }
+ if (row.kind === 'file') {
+ return 'open';
+ }
+ return row.expanded ? 'collapse' : 'expand';
+ default:
+ return 'none';
+ }
+}
+
+/** Der Cursor nach einem Schritt, an den Enden festgehalten statt umgebrochen. */
+export function moveCursor(rowCount: number, cursor: number, delta: number): number {
+ if (rowCount <= 0) {
+ return 0;
+ }
+ const next = cursor + delta;
+ if (next < 0) {
+ return 0;
+ }
+ if (next >= rowCount) {
+ return rowCount - 1;
+ }
+ return next;
+}
+
+// ------------------------------------------------- Die Coverage-Quellen -----
+
+/**
+ * Ein Pfad, wie ihn beide Quellen schreiben sollten, und manchmal nicht tun.
+ *
+ * Der Graph liefert `src/services/userService.ts`, der Coverage-Store denselben
+ * Pfad, und beide gelegentlich mit einem fuehrenden `./`. Ohne diese eine
+ * Stelle waeren `./a.ts` und `a.ts` zwei Dateien, und der Join legte zwei
+ * Zeilen fuer dieselbe Datei an.
+ */
+export function normalizeCoveragePath(path: string): string {
+ let value = path.replace(/\\/g, '/').trim();
+ while (value.startsWith('./')) {
+ value = value.slice(2);
+ }
+ while (value.startsWith('/')) {
+ value = value.slice(1);
+ }
+ while (value.endsWith('/')) {
+ value = value.slice(0, -1);
+ }
+ return value;
+}
+
+/** Ein Eintrag der `parse_partial`-Liste: Pfad plus die gemeldeten Zeilenbereiche. */
+export interface PartialFileEntry {
+ path: string;
+ /** Die Zeilenbereiche, in denen Konstrukte fehlen koennen. Roh, wie gemeldet. */
+ errorRanges: string;
+}
+
+/** Ein Eintrag der `skipped`-Liste: Pfad, Grund und die Phase des Aufgebens. */
+export interface SkippedFileEntry {
+ path: string;
+ reason: string;
+ phase: string;
+}
+
+/** Ein Eintrag der `not_indexed.files`-Liste: Pfad und die Regel, die ihn ausschliesst. */
+export interface NotIndexedFileEntry {
+ path: string;
+ reason: string;
+}
+
+/** Die Coverage-Beilage von `index_status`, so wie der Server sie schreibt. */
+export interface IndexStatusCoverage {
+ /** The index generation whose summary counts were read, when reported. */
+ generation?: string;
+ parsePartial: PartialFileEntry[];
+ parsePartialCount: number;
+ parsePartialTruncated: boolean;
+ skipped: SkippedFileEntry[];
+ skippedCount: number;
+ skippedTruncated: boolean;
+ notIndexedDirs: string[];
+ notIndexedDirsCount: number;
+ notIndexedFiles: NotIndexedFileEntry[];
+ notIndexedFilesCount: number;
+ notIndexedTruncated: boolean;
+ /** Der Satz, den der Server selbst unter die Listen schreibt. Leer, wenn keiner. */
+ note: string;
+}
+
+function coverageString(value: unknown): string {
+ return typeof value === 'string' ? value : '';
+}
+
+function coverageArray(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+
+/**
+ * Die Coverage-Beilage aus einer rohen `index_status`-Antwort lesen.
+ *
+ * Nachsichtig gegen alles, was fehlt, und streng gegen das, was behauptet wird:
+ * eine Antwort ohne die Beilage ergibt leere Listen mit Zahl null, nicht die
+ * Behauptung, es gebe keine Luecken. Der Unterschied steht in der Oberflaeche:
+ * dort ist "keine Liste" ein anderer Satz als "leere Liste".
+ */
+export function readIndexStatusCoverage(payload: unknown): IndexStatusCoverage {
+ const raw = isRecord(payload) ? payload : {};
+ const partial = isRecord(raw['parse_partial']) ? raw['parse_partial'] : {};
+ const skipped = isRecord(raw['skipped']) ? raw['skipped'] : {};
+ const notIndexed = isRecord(raw['not_indexed']) ? raw['not_indexed'] : {};
+
+ const partialFiles: PartialFileEntry[] = [];
+ for (const entry of coverageArray(partial['files'])) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const path = normalizeCoveragePath(coverageString(entry['path']));
+ if (path.length > 0) {
+ partialFiles.push({ path, errorRanges: coverageString(entry['error_ranges']) });
+ }
+ }
+
+ const skippedFiles: SkippedFileEntry[] = [];
+ for (const entry of coverageArray(skipped['files'])) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const path = normalizeCoveragePath(coverageString(entry['path']));
+ if (path.length > 0) {
+ skippedFiles.push({
+ path,
+ reason: coverageString(entry['reason']),
+ phase: coverageString(entry['phase']),
+ });
+ }
+ }
+
+ const notIndexedDirs: string[] = [];
+ for (const entry of coverageArray(notIndexed['dirs'])) {
+ const path = normalizeCoveragePath(coverageString(entry));
+ if (path.length > 0) {
+ notIndexedDirs.push(path);
+ }
+ }
+
+ const notIndexedFiles: NotIndexedFileEntry[] = [];
+ for (const entry of coverageArray(notIndexed['files'])) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const path = normalizeCoveragePath(coverageString(entry['path']));
+ if (path.length > 0) {
+ notIndexedFiles.push({ path, reason: coverageString(entry['reason']) });
+ }
+ }
+
+ return {
+ ...(coverageString(raw['indexed_at']) ? { generation: coverageString(raw['indexed_at']) } : {}),
+ parsePartial: partialFiles,
+ parsePartialCount: asNumber(partial['count']),
+ parsePartialTruncated: partial['truncated'] === true,
+ skipped: skippedFiles,
+ skippedCount: asNumber(skipped['count']),
+ skippedTruncated: skipped['truncated'] === true,
+ notIndexedDirs,
+ notIndexedDirsCount: asNumber(notIndexed['dirs_count']),
+ notIndexedFiles,
+ notIndexedFilesCount: asNumber(notIndexed['files_count']),
+ notIndexedTruncated: notIndexed['truncated'] === true,
+ note: coverageString(raw['coverage_note']),
+ };
+}
+
+/** Eine Zeile des Coverage-Stores, wie `check_index_coverage` sie ausgibt. */
+export interface CoverageRow {
+ path: string;
+ kind: string;
+ detail: string;
+ /** Nur bei einer Pfad-Abfrage: ob die Zeile den Pfad selbst oder einen Vorfahren meint. */
+ match?: string;
+}
+
+/** Ein abgefragter Scope, mit seiner Seite und der Ansage, ob noch mehr kommt. */
+export interface CoverageScope {
+ requestedScope: string;
+ scope: string;
+ status: string;
+ total: number;
+ hasMore: boolean;
+ nextOffset?: number;
+ entries: CoverageRow[];
+}
+
+/** Die Antwort zu genau einem abgefragten Pfad. */
+export interface CoveragePathAnswer {
+ requestedPath: string;
+ path: string;
+ status: string;
+ freshness: string;
+ recommendedAction: string;
+ coverage: CoverageRow[];
+}
+
+/**
+ * Die Metadaten des Coverage-Stores.
+ *
+ * `ignoredFilesStored` und `ignoredFilesTotal` sind die einzige Stelle, an der
+ * der Server ueberhaupt etwas ueber ignorierte Dateien sagt, ohne sie zu
+ * nennen. Sie werden mitgelesen, weil "der Store kennt N ignorierte Dateien,
+ * nennt aber keine" ein anderer Befund ist als "der Store kennt keine".
+ */
+export interface CoverageMetadata {
+ generation: string;
+ indexMode: string;
+ recordedAt: string;
+ recordingStatus: string;
+ ignoredFilesStored: number;
+ ignoredFilesTotal: number;
+ hashRecordsComplete: boolean;
+ coverageVersion: number;
+ generationMatches: boolean;
+}
+
+/** Eine gelesene `check_index_coverage`-Antwort. */
+export interface CoverageAnswer {
+ project: string;
+ metadata: CoverageMetadata;
+ paths: CoveragePathAnswer[];
+ scopes: CoverageScope[];
+ caveat: string;
+}
+
+function readCoverageRows(value: unknown): CoverageRow[] {
+ const rows: CoverageRow[] = [];
+ for (const entry of coverageArray(value)) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const path = normalizeCoveragePath(coverageString(entry['path']));
+ if (path.length === 0) {
+ continue;
+ }
+ const row: CoverageRow = {
+ path,
+ kind: coverageString(entry['kind']),
+ detail: coverageString(entry['detail']),
+ };
+ // The current MCP encoder replaces parse_partial's stored detail with
+ // structured ranges. Preserve that source evidence in the shared join.
+ if (row.kind === 'parse_partial' && !row.detail) {
+ const ranges = coverageArray(entry['ranges']).flatMap((range) => {
+ if (!isRecord(range)) return [];
+ const start = range['start'];
+ const end = range['end'];
+ return typeof start === 'number' && typeof end === 'number'
+ && Number.isSafeInteger(start) && Number.isSafeInteger(end)
+ && start > 0 && end >= start ? [`${start}-${end}`] : [];
+ });
+ if (ranges.length > 0) row.detail = `Source lines: ${ranges.join(', ')}`;
+ }
+ const match = coverageString(entry['match']);
+ if (match.length > 0) {
+ row.match = match;
+ }
+ rows.push(row);
+ }
+ return rows;
+}
+
+/** Eine rohe `check_index_coverage`-Antwort lesen. */
+export function readCoverageAnswer(payload: unknown): CoverageAnswer {
+ const raw = isRecord(payload) ? payload : {};
+ const meta = isRecord(raw['metadata']) ? raw['metadata'] : {};
+
+ const paths: CoveragePathAnswer[] = [];
+ for (const entry of coverageArray(raw['paths'])) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ paths.push({
+ requestedPath: coverageString(entry['requested_path']),
+ path: normalizeCoveragePath(coverageString(entry['path'])),
+ status: coverageString(entry['status']),
+ freshness: coverageString(entry['freshness']),
+ recommendedAction: coverageString(entry['recommended_action']),
+ coverage: readCoverageRows(entry['coverage']),
+ });
+ }
+
+ const scopes: CoverageScope[] = [];
+ const rawScopes = coverageArray(raw['scopes']);
+ for (const entry of rawScopes) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const scope: CoverageScope = {
+ requestedScope: coverageString(entry['requested_scope']),
+ scope: coverageString(entry['scope']),
+ status: coverageString(entry['status']),
+ total: asNumber(entry['total']),
+ // Production reports continuation on the response envelope. A
+ // scope's `truncated` also remains true on its final partial page,
+ // so that flag cannot determine whether another page is needed.
+ hasMore: typeof entry['has_more'] === 'boolean' ? entry['has_more']
+ : rawScopes.length === 1 && raw['has_more'] === true,
+ entries: readCoverageRows(entry['entries']),
+ };
+ if (entry['next_offset'] !== undefined) {
+ scope.nextOffset = asNumber(entry['next_offset']);
+ } else if (rawScopes.length === 1 && raw['next_offset'] !== undefined) {
+ scope.nextOffset = asNumber(raw['next_offset']);
+ }
+ scopes.push(scope);
+ }
+
+ return {
+ project: coverageString(raw['project']),
+ metadata: {
+ generation: coverageString(meta['generation']),
+ indexMode: coverageString(meta['index_mode']),
+ recordedAt: coverageString(meta['recorded_at']),
+ recordingStatus: coverageString(meta['recording_status']),
+ ignoredFilesStored: asNumber(meta['ignored_files_stored']),
+ ignoredFilesTotal: asNumber(meta['ignored_files_total']),
+ hashRecordsComplete: meta['hash_records_complete'] === true,
+ coverageVersion: asNumber(meta['coverage_version']),
+ generationMatches: meta['generation_matches'] === true,
+ },
+ paths,
+ scopes,
+ caveat: coverageString(raw['caveat']),
+ };
+}
+
+// --------------------------------------------------------- Der Join ---------
+
+/** Ein Befund ueber genau einen Pfad, aus allen Quellen zusammengelegt. */
+export interface CoverageRecord {
+ path: string;
+ kind: 'file' | 'dir';
+ state: CoverageState;
+ /** Der Grund des Servers. Leer heisst: er hat keinen genannt. */
+ reason: string;
+ /** Welche Quellen diesen Pfad genannt haben, in der Reihenfolge des Eintreffens. */
+ sources: string[];
+}
+
+/** Was der Join ueber ein Projekt weiss. */
+export interface CoverageIndex {
+ /** Je Pfad genau ein Befund. Was hier fehlt, hat keine Quelle genannt. */
+ records: ReadonlyMap;
+ /** Ehrliche Zeilen ueber Listen, die der Server gekappt hat. */
+ truncations: string[];
+ /** All recorded root-scope rows were returned, not all repository files indexed. */
+ listingComplete?: boolean;
+ counts: {
+ partial: number;
+ skipped: number;
+ notIndexedDirs: number;
+ notIndexedFiles: number;
+ scopeEntries: number;
+ };
+}
+
+/** Ein Join ohne jede Quelle. Nicht "alles indiziert", sondern "noch nichts gefragt". */
+export const EMPTY_COVERAGE: CoverageIndex = {
+ records: new Map(),
+ truncations: [],
+ counts: { partial: 0, skipped: 0, notIndexedDirs: 0, notIndexedFiles: 0, scopeEntries: 0 },
+};
+
+function putRecord(
+ records: Map,
+ entry: { path: string; kind: 'file' | 'dir'; state: CoverageState; reason: string; source: string },
+): void {
+ const path = normalizeCoveragePath(entry.path);
+ if (path.length === 0) {
+ return;
+ }
+ const existing = records.get(path);
+ if (existing === undefined) {
+ records.set(path, {
+ path,
+ kind: entry.kind,
+ state: entry.state,
+ reason: entry.reason,
+ sources: [entry.source],
+ });
+ return;
+ }
+ if (!existing.sources.includes(entry.source)) {
+ existing.sources.push(entry.source);
+ }
+ // Ein Ordner bleibt ein Ordner: die Scope-Liste nennt Pfade ohne Art, und
+ // eine Datei daraus duerfte einen bekannten Ordner nicht ueberschreiben.
+ if (entry.kind === 'dir') {
+ existing.kind = 'dir';
+ }
+ const worst = worstCoverage(existing.state, entry.state);
+ if (worst !== existing.state) {
+ existing.state = worst;
+ existing.reason = entry.reason;
+ } else if (existing.reason.length === 0) {
+ existing.reason = entry.reason;
+ }
+}
+
+/**
+ * Die Quellen zu einem Befund je Pfad zusammenlegen.
+ *
+ * Der Graph steht hier nicht: er sagt ueber eine Datei nur, dass sie da ist,
+ * und "da" ist die Abwesenheit eines Befundes. Wer in keiner Liste steht, gilt
+ * als `indexed`, und genau das ist die Konfliktregel: eine Datei, die im
+ * Graphen UND in `parse_partial` steht, ist partiell, nicht vollstaendig.
+ */
+export function buildCoverageIndex(input: {
+ status?: IndexStatusCoverage | undefined;
+ scopes?: readonly CoverageScope[] | undefined;
+}): CoverageIndex {
+ const records = new Map();
+ const truncations: string[] = [];
+ const counts = { partial: 0, skipped: 0, notIndexedDirs: 0, notIndexedFiles: 0, scopeEntries: 0 };
+ const scopeReadings = new Map }>();
+ for (const scope of input.scopes ?? []) {
+ const key = scope.scope || '.';
+ const reading = scopeReadings.get(key) ?? { last: scope, total: scope.total, consistent: true, rows: new Map() };
+ reading.consistent &&= scope.total === reading.total
+ && ['known_gaps', 'no_recorded_issue', 'complete'].includes(scope.status);
+ reading.last = scope;
+ for (const row of scope.entries) reading.rows.set(`${row.path}\0${row.kind}`, row);
+ scopeReadings.set(key, reading);
+ }
+ const complete = (reading: NonNullable>) =>
+ reading.consistent && !reading.last.hasMore && reading.rows.size === reading.total;
+ const rootReading = scopeReadings.get('.');
+ const listingComplete = rootReading !== undefined && complete(rootReading);
+ const rootRows = listingComplete ? [...rootReading!.rows.values()] : [];
+ const listedKind = (kind: string) => rootRows.filter((row) => row.kind === kind).length;
+
+ const status = input.status;
+ if (status !== undefined) {
+ for (const entry of status.parsePartial) {
+ putRecord(records, {
+ path: entry.path,
+ kind: 'file',
+ state: 'partial',
+ reason: entry.errorRanges,
+ source: 'index_status.parse_partial',
+ });
+ }
+ for (const entry of status.skipped) {
+ putRecord(records, {
+ path: entry.path,
+ kind: 'file',
+ state: 'skipped',
+ reason: [entry.reason, entry.phase].filter((part) => part.length > 0).join(' / '),
+ source: 'index_status.skipped',
+ });
+ }
+ for (const path of status.notIndexedDirs) {
+ putRecord(records, {
+ path,
+ kind: 'dir',
+ state: 'not-indexed',
+ reason: '',
+ source: 'index_status.not_indexed.dirs',
+ });
+ }
+ for (const entry of status.notIndexedFiles) {
+ putRecord(records, {
+ path: entry.path,
+ kind: 'file',
+ state: 'not-indexed',
+ reason: entry.reason,
+ source: 'index_status.not_indexed.files',
+ });
+ }
+ counts.partial = status.parsePartialCount;
+ counts.skipped = status.skippedCount;
+ counts.notIndexedDirs = status.notIndexedDirsCount;
+ counts.notIndexedFiles = status.notIndexedFilesCount;
+
+ if (status.parsePartialTruncated && (!listingComplete || listedKind('parse_partial') < status.parsePartialCount)) {
+ truncations.push(
+ `the server cut the parse_partial list: ${status.parsePartialCount} recorded, fewer listed`,
+ );
+ }
+ const listedSkipped = rootRows.filter((row) => coverageStateForKind(row.kind) === 'skipped').length;
+ if (status.skippedTruncated && (!listingComplete || listedSkipped < status.skippedCount)) {
+ truncations.push(
+ `the server cut the skipped list: ${status.skippedCount} recorded, fewer listed`,
+ );
+ }
+ if (status.notIndexedTruncated && (!listingComplete || listedKind('not_indexed_dir') < status.notIndexedDirsCount || listedKind('not_indexed_file') < status.notIndexedFilesCount)) {
+ truncations.push(
+ 'the server cut the not_indexed list: '
+ + `${status.notIndexedDirsCount} folders and ${status.notIndexedFilesCount} files recorded, fewer listed`,
+ );
+ }
+ }
+
+ for (const scope of input.scopes ?? []) {
+ for (const row of scope.entries) {
+ const state = coverageStateForKind(row.kind);
+ putRecord(records, {
+ path: row.path,
+ kind: row.kind === 'not_indexed_dir' ? 'dir' : 'file',
+ state,
+ reason: row.detail,
+ source: `check_index_coverage.scope:${scope.scope.length > 0 ? scope.scope : '.'}`,
+ });
+ counts.scopeEntries += 1;
+ }
+ }
+ for (const [scope, reading] of scopeReadings) {
+ if (!complete(reading)) {
+ truncations.push(
+ `the coverage reading under "${scope}" is incomplete or inconsistent: `
+ + `${reading.total} recorded, ${reading.rows.size} distinct rows loaded`,
+ );
+ }
+ }
+
+ return { records, truncations, counts, listingComplete };
+}
+
+/**
+ * Die schlechteste Stufe unter einem Ordner, seine eigene eingeschlossen.
+ *
+ * Ueber den Coverage-Index und nicht ueber die geladenen Ebenen, und das ist
+ * der Grund, warum der Punkt-Marker eines zugeklappten Ordners stimmt: die
+ * Ebenen kommen einzeln und spaet, die Befunde kommen vollstaendig und frueh.
+ */
+export function aggregateCoverage(index: CoverageIndex, dirPath: string): CoverageState {
+ const prefix = `${dirPath}/`;
+ let worst: CoverageState = 'indexed';
+ for (const record of index.records.values()) {
+ if (record.path === dirPath || record.path.startsWith(prefix)) {
+ worst = worstCoverage(worst, record.state);
+ }
+ }
+ return worst;
+}
+
+function ensureLevel(levels: Map, path: string): TreeLevel {
+ const existing = levels.get(path);
+ if (existing !== undefined) {
+ return existing;
+ }
+ const created: TreeLevel = {
+ path,
+ files: 0,
+ symbols: 0,
+ children: [],
+ foldedDuplicates: 0,
+ droppedNonPaths: 0,
+ synthetic: true,
+ };
+ levels.set(path, created);
+ return created;
+}
+
+function graftPath(levels: Map, path: string, kind: 'file' | 'dir'): void {
+ const parent = parentPath(path);
+ if (parent.length > 0) {
+ graftPath(levels, parent, 'dir');
+ }
+ const level = ensureLevel(levels, parent);
+ if (level.children.some((child) => child.path === path)) {
+ return;
+ }
+ level.children.push({
+ name: path.slice(path.lastIndexOf('/') + 1),
+ path,
+ kind,
+ symbols: 0,
+ files: 0,
+ });
+}
+
+/**
+ * Die Vereinigung: die Ebenen aus dem Graphen, ergaenzt um jeden Pfad, den nur
+ * die Coverage-Listen kennen, und jede Zeile mit ihrer Stufe versehen.
+ *
+ * Die Ebenen werden kopiert und nicht veraendert. Ein Join, der die Antworten
+ * des Servers an Ort und Stelle beschriftet, macht aus zweimal Lesen zweimal
+ * etwas anderes, und der Explorer zeichnet oefter, als der Server antwortet.
+ */
+export function mergeCoverageIntoLevels(
+ levels: ReadonlyMap,
+ index: CoverageIndex,
+): Map {
+ const merged = new Map();
+ for (const [path, level] of levels) {
+ merged.set(path, { ...level, children: level.children.map((child) => ({ ...child })) });
+ }
+
+ for (const record of index.records.values()) {
+ graftPath(merged, record.path, record.kind);
+ }
+
+ for (const level of merged.values()) {
+ for (const child of level.children) {
+ const record = index.records.get(child.path);
+ child.coverage = child.kind === 'dir'
+ ? aggregateCoverage(index, child.path)
+ : record?.state ?? 'indexed';
+ if (record !== undefined && record.reason.length > 0) {
+ child.coverageReason = record.reason;
+ }
+ if (record !== undefined) {
+ child.coverageSources = [...record.sources];
+ }
+ }
+ level.children.sort(compareChildren);
+ }
+
+ return merged;
+}
diff --git a/graph-ui/src/app/ui-log-install.test.ts b/graph-ui/src/app/ui-log-install.test.ts
new file mode 100644
index 000000000..87c163903
--- /dev/null
+++ b/graph-ui/src/app/ui-log-install.test.ts
@@ -0,0 +1,257 @@
+// @vitest-environment jsdom
+/*
+ * The frontend log wired into a page: the console still prints, and what it
+ * printed is recorded; uncaught errors, rejected promises and the failures
+ * the clients announce all reach the buffer; uninstall puts the console
+ * back. The transport is a recorder, so nothing here touches a network.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { reportError } from '../provider/error-observer';
+import { repeatCount, UiLogBuffer } from './ui-log';
+import type { UiLogPayload } from './ui-log';
+import { describeArgs, installUiLog, safeStringify } from './ui-log-install';
+import type { UiLogHandle } from './ui-log-install';
+import { httpUiLogTransport } from './ui-log-transport';
+
+let handle: UiLogHandle | undefined;
+
+afterEach(() => {
+ handle?.uninstall();
+ handle = undefined;
+});
+
+function install(getProject?: () => string) {
+ const posts: { payload: UiLogPayload; final: boolean }[] = [];
+ const buffer = new UiLogBuffer({
+ page: '/?project=demo',
+ session: 'test',
+ transport: {
+ send: async (payload, final) => {
+ posts.push({ payload, final });
+ return true;
+ },
+ },
+ schedule: () => 0,
+ cancel: () => undefined,
+ });
+ const printed: unknown[][] = [];
+ const fakeConsole = {
+ debug: (...args: unknown[]) => printed.push(['debug', ...args]),
+ log: (...args: unknown[]) => printed.push(['log', ...args]),
+ info: (...args: unknown[]) => printed.push(['info', ...args]),
+ warn: (...args: unknown[]) => printed.push(['warn', ...args]),
+ error: (...args: unknown[]) => printed.push(['error', ...args]),
+ } as unknown as Console;
+ handle = installUiLog({ buffer, console: fakeConsole, session: 'test', getProject });
+ return { buffer, posts, printed, fakeConsole };
+}
+
+async function entries(buffer: UiLogBuffer, posts: { payload: UiLogPayload }[]) {
+ await buffer.flush();
+ return posts.flatMap((post) => post.payload.entries);
+}
+
+describe('installUiLog', () => {
+ it('captures console ownership before flushing and retains the failed request project', async () => {
+ let project = 'A';
+ const { buffer, posts, fakeConsole } = install(() => project);
+ fakeConsole.warn('A warning');
+ project = 'B';
+ fakeConsole.error('B error');
+ reportError({ project: 'A', source: 'rpc', level: 'error', message: 'late A request' });
+ reportError({ project: '', source: 'api', level: 'error', message: 'daemon request' });
+ const all = await entries(buffer, posts);
+ expect(all.map((entry) => [entry.message, entry.project])).toEqual([
+ ['session test started on /', 'A'], ['A warning', 'A'], ['B error', 'B'],
+ ['late A request', 'A'], ['daemon request', ''],
+ ]);
+ });
+
+ it('starts the session with a line that names page, build and browser', async () => {
+ const { buffer, posts } = install();
+ const all = await entries(buffer, posts);
+ expect(all[0]?.source).toBe('ui-log');
+ expect(all[0]?.message).toBe('session test started on /');
+ expect(all[0]?.detail).toContain('build v');
+ });
+
+ it('posts a line under the address it was written on, also after a project switch in the page (K24)', async () => {
+ window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy');
+ const posts: UiLogPayload[] = [];
+ const quiet = { debug: () => {}, log: () => {}, info: () => {}, warn: () => {}, error: () => {} } as unknown as Console;
+ try {
+ handle = installUiLog({ console: quiet, session: 'switch', transport: { send: async (payload) => { posts.push(payload); return true; } } });
+ // The project switch changes the address with pushState and keeps the page (app/project-windows.tsx).
+ window.history.pushState(null, '', '/?project=cbm&workspace=galaxy');
+ quiet.warn('after the switch');
+ await handle.buffer.flush();
+ await handle.buffer.flush();
+ expect(posts.map((post) => [post.page, post.entries.map((entry) => [entry.message, entry.project])])).toEqual([
+ ['/?project=django-demo&workspace=galaxy', [['session switch started on /?project=django-demo&workspace=galaxy', 'django-demo']]],
+ ['/?project=cbm&workspace=galaxy', [['after the switch', 'cbm']]],
+ ]);
+ } finally {
+ window.history.replaceState(null, '', '/');
+ }
+ });
+
+ it('lets the console print as before and records what it printed', async () => {
+ const { buffer, posts, printed, fakeConsole } = install();
+ const failure = new Error('twin failed');
+ fakeConsole.error('reader', failure);
+ fakeConsole.warn('slow frame', { ms: 41 });
+ fakeConsole.log('plain');
+ expect(printed).toEqual([['error', 'reader', failure], ['warn', 'slow frame', { ms: 41 }], ['log', 'plain']]);
+
+ const all = await entries(buffer, posts);
+ const fromConsole = all.filter((entry) => entry.source === 'console');
+ expect(fromConsole.map((entry) => [entry.level, entry.message])).toEqual([
+ ['error', 'reader Error: twin failed'],
+ ['warn', 'slow frame {"ms":41}'],
+ ['log', 'plain'],
+ ]);
+ expect(fromConsole[0]?.stack).toContain('twin failed');
+ });
+
+ it('records an uncaught error with its file, line and column', async () => {
+ const { buffer, posts } = install();
+ window.dispatchEvent(new ErrorEvent('error', {
+ message: 'Uncaught TypeError: x is not a function',
+ filename: 'http://127.0.0.1:9749/assets/index.js',
+ lineno: 12,
+ colno: 7,
+ error: new TypeError('x is not a function'),
+ }));
+ const all = await entries(buffer, posts);
+ const uncaught = all.find((entry) => entry.source === 'window');
+ expect(uncaught?.level).toBe('error');
+ expect(uncaught?.message).toBe('Uncaught TypeError: x is not a function');
+ expect(uncaught?.url).toBe('http://127.0.0.1:9749/assets/index.js');
+ expect(uncaught?.line).toBe(12);
+ expect(uncaught?.col).toBe(7);
+ expect(uncaught?.stack).toContain('x is not a function');
+ });
+
+ it('records an unhandled rejection', async () => {
+ const { buffer, posts } = install();
+ const event = new Event('unhandledrejection') as Event & { reason: unknown };
+ event.reason = new Error('nobody caught this');
+ window.dispatchEvent(event);
+ const all = await entries(buffer, posts);
+ const rejected = all.find((entry) => entry.source === 'promise');
+ expect(rejected?.level).toBe('error');
+ expect(rejected?.message).toBe('Error: nobody caught this');
+ });
+
+ it('records what the clients announce through the observer seam', async () => {
+ const { buffer, posts } = install();
+ reportError({ source: 'rpc', level: 'error', message: '/rpc get_code_snippet: HTTP 500', detail: 'oom' });
+ const all = await entries(buffer, posts);
+ const announced = all.find((entry) => entry.source === 'rpc');
+ expect(announced?.message).toBe('/rpc get_code_snippet: HTTP 500');
+ expect(announced?.detail).toBe('oom');
+ });
+
+ it('flushes with final when the page hides', () => {
+ const { buffer } = install();
+ const flush = vi.spyOn(buffer, 'flush');
+ window.dispatchEvent(new Event('pagehide'));
+ expect(flush).toHaveBeenCalledWith(true);
+ });
+
+ it('puts the console back on uninstall and stops listening', async () => {
+ const { buffer, posts, printed, fakeConsole } = install();
+ const wrapped = fakeConsole.log;
+ handle?.uninstall();
+ handle = undefined;
+ expect(fakeConsole.log).not.toBe(wrapped);
+ fakeConsole.log('after');
+ reportError({ source: 'api', level: 'warn', message: 'late' });
+ expect(printed[printed.length - 1]).toEqual(['log', 'after']);
+ const all = await entries(buffer, posts);
+ expect(all.some((entry) => entry.message === 'after')).toBe(false);
+ expect(all.some((entry) => entry.message === 'late')).toBe(false);
+ });
+});
+
+describe('describeArgs and safeStringify', () => {
+ it('renders strings as they are, errors with their name, objects as JSON with cycles held', () => {
+ const loop: Record = { name: 'loop' };
+ loop['self'] = loop;
+ expect(describeArgs(['a', 1, true, null, undefined, loop]).message)
+ .toBe('a 1 true null undefined {"name":"loop","self":"[circular]"}');
+ expect(safeStringify(10n)).toBe('10n');
+ expect(safeStringify(() => 1)).toBe('[function anonymous]');
+ // A string is passed through as it is; the field cap applies later in
+ // the buffer. An object is cut here, so one huge argument cannot
+ // crowd out the rest of the line.
+ expect(safeStringify('x'.repeat(2500)).length).toBe(2500);
+ expect(safeStringify({ s: 'x'.repeat(2500) }).length).toBeLessThan(2100);
+ });
+});
+
+describe('httpUiLogTransport', () => {
+ it('posts JSON to /api/ui-log and reports the server verdict', async () => {
+ const calls: { url: string; init: RequestInit | undefined }[] = [];
+ const transport = httpUiLogTransport({
+ base: 'http://127.0.0.1:9749',
+ fetch: ((url: string, init?: RequestInit) => {
+ calls.push({ url, init });
+ return Promise.resolve({ ok: true } as Response);
+ }) as unknown as typeof globalThis.fetch,
+ });
+ const payload: UiLogPayload = { page: '/', session: 's', entries: [] };
+ expect(await transport.send(payload, false)).toBe(true);
+ expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/ui-log');
+ expect(calls[0]?.init?.method).toBe('POST');
+ expect((calls[0]?.init?.headers as Record)['Content-Type']).toBe('application/json');
+ expect(calls[0]?.init?.body).toBe(JSON.stringify(payload));
+ expect(calls[0]?.init?.keepalive).toBe(false);
+ });
+
+ it('uses the beacon for a final batch and never throws', async () => {
+ const beacons: { url: string; type: string }[] = [];
+ const transport = httpUiLogTransport({
+ beacon: (url, body) => {
+ beacons.push({ url, type: body.type });
+ return true;
+ },
+ fetch: (() => {
+ throw new Error('must not be used for a final batch');
+ }) as unknown as typeof globalThis.fetch,
+ });
+ expect(await transport.send({ page: '/', session: 's', entries: [] }, true)).toBe(true);
+ expect(beacons).toEqual([{ url: '/api/ui-log', type: 'application/json' }]);
+
+ const failing = httpUiLogTransport({
+ fetch: (() => Promise.reject(new Error('down'))) as unknown as typeof globalThis.fetch,
+ });
+ expect(await failing.send({ page: '/', session: 's', entries: [] }, false)).toBe(false);
+ });
+
+ it('sends 120 identical console warnings through the transport as one entry with a count', async () => {
+ const bodies: UiLogPayload[] = [];
+ const fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return new Response('{}'); });
+ const beaconed: Promise[] = [];
+ const beacon = vi.fn((_url: string, body: Blob) => { beaconed.push(new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.readAsText(body); }).then((text) => { bodies.push(JSON.parse(text)); })); return true; });
+ const buffer = new UiLogBuffer({ page: '/?project=cbm', session: 'three', transport: httpUiLogTransport({ fetch: fetch as typeof globalThis.fetch, beacon }),
+ schedule: () => 0, cancel: () => undefined, bufferMax: 200, batchMax: 200 });
+ const printed: unknown[] = [];
+ const fakeConsole = { debug: () => undefined, log: () => undefined, info: () => undefined, error: () => undefined,
+ warn: (...args: unknown[]) => printed.push(args[0]) } as unknown as Console;
+ handle = installUiLog({ buffer, console: fakeConsole, session: 'three', getProject: () => 'cbm' });
+ const clock = 'THREE.THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.';
+ for (let i = 0; i < 120; i++) fakeConsole.warn(clock);
+ await buffer.flush();
+ window.dispatchEvent(new Event('pagehide'));
+ await vi.waitFor(() => expect(beaconed).toHaveLength(1));
+ await Promise.all(beaconed);
+ // The console still prints every call; the server gets the first in full and the counts.
+ expect(printed).toHaveLength(120);
+ const posted = bodies.flatMap((body) => body.entries).filter((entry) => entry.message === clock);
+ expect(posted.map((entry) => repeatCount(entry.detail) ?? 1)).toEqual([1, 10, 100, 120]);
+ expect(beacon).toHaveBeenCalledOnce();
+ });
+});
diff --git a/graph-ui/src/app/ui-log-install.ts b/graph-ui/src/app/ui-log-install.ts
new file mode 100644
index 000000000..2ed30ea17
--- /dev/null
+++ b/graph-ui/src/app/ui-log-install.ts
@@ -0,0 +1,241 @@
+/**
+ * Wiring the frontend log into a page: console, window, promises, clients.
+ *
+ * Four feeds, and each one is a place where an error would otherwise stop:
+ *
+ * 1. **The console.** Every console method still prints, exactly as before;
+ * the wrapper calls the original first and records second. Arguments
+ * are rendered the way a reader would want them in a file: strings as
+ * they are, an Error as name, message and stack, anything else as JSON
+ * with cycles and length held in check.
+ * 2. **Uncaught errors** (`window.onerror` as the `error` event), with file,
+ * line and column when the browser has them.
+ * 3. **Unhandled promise rejections.**
+ * 4. **Caught request failures**, announced by the /rpc and /api clients
+ * through the observer seam (provider/error-observer.ts). These are the
+ * ones the panels show as a sentence and the console never sees.
+ *
+ * The buffer is flushed with `final` on pagehide and when the tab is hidden,
+ * so the last lines of a session reach the file (ui-log-transport.ts uses a
+ * beacon for that). A re-entrancy guard makes sure nothing recorded from
+ * inside a record call comes back around.
+ *
+ * The first entry of a session says which page, which build and which
+ * browser, because a log file that holds several sessions needs a line
+ * where each one starts.
+ */
+
+import { observeErrors } from '../provider/error-observer';
+import { UiLogBuffer } from './ui-log';
+import type { UiLogLevel, UiLogTransport } from './ui-log';
+import { httpUiLogTransport } from './ui-log-transport';
+import { ATLAS_BUILD_SUFFIX, ATLAS_VERSION } from './build-info';
+
+export interface InstallUiLogOptions {
+ target?: Window;
+ console?: Console;
+ transport?: UiLogTransport;
+ buffer?: UiLogBuffer;
+ session?: string;
+ getProject?: () => string;
+}
+
+let activeProject: string | undefined;
+
+/** The application also resolves projects without changing the page URL. */
+export function setUiLogProject(project: string): void {
+ activeProject = project;
+}
+
+export interface UiLogHandle {
+ buffer: UiLogBuffer;
+ session: string;
+ uninstall(): void;
+}
+
+const LEVELS: readonly UiLogLevel[] = ['debug', 'log', 'info', 'warn', 'error'];
+
+/** The cap on a rendered console argument; the field cap applies after. */
+const ARG_MAX = 2000;
+
+/** JSON for a console argument that is not a string or an Error. */
+export function safeStringify(value: unknown, max = ARG_MAX): string {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (typeof value === 'bigint') {
+ return `${value.toString()}n`;
+ }
+ if (typeof value === 'function') {
+ return `[function ${value.name || 'anonymous'}]`;
+ }
+ if (typeof value === 'symbol') {
+ return value.toString();
+ }
+ if (value === undefined) {
+ return 'undefined';
+ }
+ const seen = new WeakSet
} />));
+ expect(host.textContent).toContain('Selected context');
+ expect(host.textContent).not.toContain('Legacy twin');
+ expect(host.textContent).not.toContain('Legacy sidecar');
+ expect(host.querySelector('[data-testid="legacy-twin-splitter"]')).toBeNull();
+
+ await act(async () => root.render());
+ expect(host.textContent).toContain('Legacy twin');
+ expect(host.textContent).toContain('Legacy sidecar');
+ expect(host.querySelector('[data-testid="legacy-twin-splitter"]')).not.toBeNull();
+});
+
+it.each(['ctrlKey', 'metaKey'] as const)('routes %s+K to Galaxy and focuses its dedicated search after navigation', async modifier => {
+ const onWorkspaceChange = vi.fn(); const focused = vi.fn();
+ let frame: FrameRequestCallback | undefined;
+ const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frame = callback; return 1; });
+ window.addEventListener('cbm:focus-galaxy-search', focused);
+ try {
+ await act(async () => root.render());
+ const reader = host.querySelector('[aria-label="Preserved reader"]')!;
+ reader.focus();
+ const event = new KeyboardEvent('keydown', { key: 'k', [modifier]: true, bubbles: true, cancelable: true });
+ await act(async () => window.dispatchEvent(event));
+ expect(event.defaultPrevented).toBe(true);
+ expect(onWorkspaceChange).toHaveBeenCalledExactlyOnceWith('galaxy');
+ expect(focused).not.toHaveBeenCalled();
+ expect(host.querySelector('dialog')).toBeNull();
+ expect(host.querySelector('[data-testid="atlas-command-input"]')).toBeNull();
+ await act(async () => frame!(0));
+ expect(focused).toHaveBeenCalledOnce();
+ expect(reader.value).toBe('selected source');
+ } finally { window.removeEventListener('cbm:focus-galaxy-search', focused); raf.mockRestore(); }
+});
+
+it('routes legacy graph search requests to the dedicated Galaxy workspace', async () => {
+ const onWorkspaceChange = vi.fn(); const focused = vi.fn();
+ let frame: FrameRequestCallback | undefined;
+ const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frame = callback; return 1; });
+ window.addEventListener('cbm:focus-galaxy-search', focused);
+ try {
+ await act(async () => root.render());
+ await act(async () => window.dispatchEvent(new Event('cbm:open-command-search')));
+ expect(onWorkspaceChange).toHaveBeenCalledExactlyOnceWith('galaxy');
+ await act(async () => frame!(0));
+ expect(focused).toHaveBeenCalledOnce();
+ expect(host.querySelector('dialog')).toBeNull();
+ } finally { window.removeEventListener('cbm:focus-galaxy-search', focused); raf.mockRestore(); }
+});
+
+it('shows compact reader context alongside preserved code without mounting a full impact report', async () => {
+ const props = makeProps();
+ await act(async () => root.render(3 dependent files · 2 tests} />));
+ const reader = host.querySelector('[aria-label="Preserved reader"]')!;
+ const summary = host.querySelector('[aria-label="File impact"]')!;
+ expect(summary.textContent).toContain('3 dependent files');
+ expect(summary.compareDocumentPosition(reader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(reader.closest('[hidden]')).toBeNull();
+ expect(host.querySelector('[data-testid="impact-workspace"]')).toBeNull();
+ await act(async () => root.render(Selection · 1 dependent file} />));
+ expect(host.querySelector('[aria-label="Preserved reader"]')).toBe(reader);
+ expect(reader.value).toBe('selected source');
+ expect(host.querySelector('[aria-label="File impact"]')?.textContent).toContain('Selection');
+});
+
+it('resizes local chat by keyboard and exposes daemon navigation', async () => {
+ const onOpenSystem = vi.fn();
+ await act(async () => root.render(Local chat}
+ onOpenSystem={onOpenSystem} daemonState="disconnected" />));
+ const separator = host.querySelector('[data-testid="atlas-split-chat"]')!;
+ expect(separator.getAttribute('aria-valuenow')).toBe('420');
+ await act(async () => separator.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })));
+ expect(Number(separator.getAttribute('aria-valuenow'))).toBeGreaterThan(420);
+ await act(async () => host.querySelector('[aria-label="Open System: daemon disconnected"]')!.click());
+ expect(onOpenSystem).toHaveBeenCalledOnce();
+});
+
+it('keeps the reader mounted while showing a different workspace', async () => {
+ const props = makeProps();
+ const change = vi.fn();
+ await act(async () => root.render());
+ const reader = host.querySelector('input[aria-label="Preserved reader"]');
+ await act(async () => root.render(Architecture evidence,
+ }} />));
+ expect(host.textContent).toContain('Architecture evidence');
+ expect(host.querySelector('input[aria-label="Preserved reader"]')).toBe(reader);
+ expect(host.querySelector('[data-testid="atlas-exploration-workspace"]')?.hasAttribute('hidden')).toBe(true);
+});
+
+it('keeps tools and explanation-depth controls out of the header', async () => {
+ await act(async () => root.render());
+ const header = host.querySelector('[data-testid="atlas-header"]')!;
+ expect(header.querySelector('.atlas-tools-menu')).toBeNull();
+ expect(header.querySelector('[aria-label="Explanation depth"]')).toBeNull();
+ expect(header.querySelector('.atlas-search-action')).toBeNull();
+ expect([...header.querySelectorAll('button')].some(button => button.textContent === 'Edges')).toBe(false);
+});
+
+it('places separate agent settings and chat controls directly after the project selector', async () => {
+ const onOpenBrowserAi = vi.fn();
+ const onToggleBrowserAi = vi.fn();
+ await act(async () => root.render(Project}
+ onOpenBrowserAi={onOpenBrowserAi} onToggleBrowserAi={onToggleBrowserAi} chatOpen />));
+ const project = host.querySelector('[data-testid="project-selector"]')!;
+ const controls = project.nextElementSibling!;
+ expect(controls.getAttribute('aria-label')).toBe('Local agent');
+ const settings = controls.querySelector('.atlas-browser-ai-action')!;
+ const chat = controls.querySelector('[aria-label="Hide chat"]')!;
+ expect(chat.getAttribute('aria-expanded')).toBe('true');
+ await act(async () => chat.click());
+ expect(onToggleBrowserAi).toHaveBeenCalledOnce();
+ expect(onOpenBrowserAi).not.toHaveBeenCalled();
+ await act(async () => settings.click());
+ expect(onOpenBrowserAi).toHaveBeenCalledOnce();
+});
+
+it('leaves reader typing and Enter untouched by removed command handlers', async () => {
+ const onCommandChange = vi.fn(); const onCommandKeyDown = vi.fn();
+ await act(async () => root.render());
+ const reader = host.querySelector('[aria-label="Preserved reader"]')!;
+ reader.focus();
+ const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
+ await act(async () => reader.dispatchEvent(enter));
+ expect(enter.defaultPrevented).toBe(false);
+ expect(onCommandKeyDown).not.toHaveBeenCalled();
+ expect(onCommandChange).not.toHaveBeenCalled();
+ expect(document.activeElement).toBe(reader);
+ expect(host.querySelector('dialog')).toBeNull();
+});
+
+it('keeps Explorer graph and chat draft mounted when chat folds', async () => {
+ const props = makeProps();
+ const galaxy = ;
+ const chat = ;
+ const render = (chatOpen: boolean) => root.render();
+ await act(async () => render(false));
+ const canvas = host.querySelector('canvas');
+ const draft = host.querySelector('[aria-label="Folded chat draft"]');
+ expect(canvas?.closest('.atlas-chat-column')?.hasAttribute('hidden')).toBe(false);
+ expect(canvas?.closest('[hidden]')).toBeNull();
+ expect(host.querySelector('[data-testid="atlas-split-chat-graph"]')).toBeNull();
+ await act(async () => render(true));
+ expect(host.querySelector('canvas')).toBe(canvas);
+ expect(host.querySelector('[data-testid="atlas-split-chat-graph"]')).not.toBeNull();
+ await act(async () => render(false));
+ expect(host.querySelector('canvas')).toBe(canvas);
+ expect(host.querySelector('[aria-label="Folded chat draft"]')).toBe(draft);
+ expect(canvas?.closest('[hidden]')).toBeNull();
+});
diff --git a/graph-ui/src/app/workspace-strings.ts b/graph-ui/src/app/workspace-strings.ts
new file mode 100644
index 000000000..f4b888f38
--- /dev/null
+++ b/graph-ui/src/app/workspace-strings.ts
@@ -0,0 +1,87 @@
+import { experimentalAgentsEnabled } from './feature-flags';
+
+export type Workspace = 'explore' | 'galaxy' | 'architecture' | 'adr' | 'agents' | 'coverage' | 'system';
+export type Guidance = 'brief' | 'explained';
+
+export const workspaceStrings = {
+ navigation: 'Workspace',
+ workspaces: [
+ { id: 'explore' as const, label: 'Explore' },
+ { id: 'galaxy' as const, label: 'Galaxy' },
+ { id: 'architecture' as const, label: 'Architecture' },
+ { id: 'adr' as const, label: 'ADR' },
+ { id: 'agents' as const, label: 'Agents' },
+ { id: 'coverage' as const, label: 'Coverage' },
+ { id: 'system' as const, label: 'System' },
+ ],
+ tools: 'Tools',
+ search: 'Search',
+ searchOpen: 'Open search and commands',
+ searchTitle: 'Search and commands',
+ searchPlaceholder: 'Find a symbol, search by meaning, or run a command',
+ searchClose: 'Close search',
+ loadedSearchHeadline: (query: string, count: number) => `${count} loaded ${count === 1 ? 'match' : 'matches'} for "${query}"; no matching index results.`,
+ searchShortcut: '⌘ / Ctrl K',
+ searchExamples: 'Try a search or graph command',
+ chatGraphHeight: 'Height of graph below chat',
+ guidance: 'Explanation depth',
+ brief: 'Brief',
+ briefValue: 'brief',
+ explained: 'Explained',
+ explainedValue: 'explained',
+ diagnostics: 'Connection details',
+ coverage: 'Index details',
+ model: 'Model details',
+ perspective: 'Reading preferences',
+ welcomeEyebrow: 'Codebase memory',
+ welcomeTitle: 'A clearer view of your code.',
+ welcomeDescription: 'Explore the source and understand its architecture.',
+ descriptions: {
+ explore: 'Read source with graph context.',
+ galaxy: 'Explore the whole graph and its coverage shadow.',
+ architecture: 'See modules, dependencies, and entry points.',
+ adr: 'Read and edit the project’s architecture decisions.',
+ agents: 'Inspect recorded activity and touched code.',
+ coverage: 'Inspect indexed paths, exclusions, and parser gaps.',
+ system: 'Monitor the daemon, indexes, and logs.',
+ },
+ changeLater: 'Switch workspaces at any time.',
+ localAi: 'Set up browser AI',
+ browserAi: 'Chat',
+ agentControls: 'Local agent',
+ agentStatus: { off: 'Agent off', loading: 'Agent loading', active: 'Agent active', busy: 'Agent working', error: 'Agent error' },
+ /** The header shows a robot and a lamp; state and model are read out and shown on hover. */
+ agentSettings: (status: string, model?: string) => `Local agent settings: ${status}${model ? `, ${model}` : ''}`,
+ agentSettingsTitle: (status: string, model?: string) => `${status}${model ? ` · ${model}` : ''}. Configure the browser-local agent.`,
+ openChat: 'Open chat',
+ hideChat: 'Hide chat',
+ daemon: 'Daemon',
+ daemonNavigation: (state: string) => `Open System: daemon ${state}`,
+ chatWidth: 'Width of local chat',
+ askSelection: 'Ask about selection',
+ assessImpact: 'Assess impact',
+ codeDetails: 'Code details',
+ indexInventory: 'Index inventory and legend',
+ selectionImpact: 'Assess change impact',
+ changeAnalysis: 'Change analysis',
+ sourceOutsideSnapshot: 'This file is outside the loaded index snapshot.',
+ selectionEvidence: 'Selection evidence',
+ graphContext: 'Graph context',
+ coverageShadowSelection: (name: string) => `Coverage shadow: ${name}`,
+ start: 'Start exploring',
+ aiOptional: 'Browser AI is optional and off. Setup asks before downloading a model.',
+};
+
+export function availableWorkspaces(agentsEnabled = experimentalAgentsEnabled) {
+ return workspaceStrings.workspaces.filter(workspace => agentsEnabled || workspace.id !== 'agents');
+}
+
+export function allowedWorkspace(workspace: Workspace, agentsEnabled = experimentalAgentsEnabled): Workspace {
+ return workspace === 'agents' && !agentsEnabled ? 'explore' : workspace;
+}
+
+/** An unavailable explicit or saved workspace falls back to the source reader. */
+export function initialWorkspace(requested: string | null, saved: string | null, agentsEnabled = experimentalAgentsEnabled): Workspace {
+ const known = (value: string | null): value is Workspace => workspaceStrings.workspaces.some(workspace => workspace.id === value);
+ return allowedWorkspace(known(requested) ? requested : known(saved) ? saved : 'architecture', agentsEnabled);
+}
diff --git a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx
new file mode 100644
index 000000000..727962118
--- /dev/null
+++ b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx
@@ -0,0 +1,597 @@
+// @vitest-environment jsdom
+/*
+ * Handtest K27: ein Zurueck und ein Vor fuer ganz Architecture, mit demselben
+ * Verlaufsmodell wie Galaxy (K2). Die Szenen sind Attrappen mit Knoepfen fuer
+ * Auswahl, Doppelklick und leere Flaeche; Analyse und Routen kommen aus
+ * festen Antworten.
+ */
+import { act, type ReactNode } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import ArchitecturePanel, { type ArchitecturePanelProps } from './ArchitecturePanel';
+import type { ArchitectureSceneProps } from './ArchitectureScene';
+import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
+import type { GraphData, GraphNode } from '../galaxy/types';
+import type { SystemSceneModel } from './system-architecture-model';
+import type { SystemArchitectureLoader, SystemProjection, SystemSymbol } from './system-architecture-source';
+
+vi.mock('./ArchitectureScene', () => ({ ArchitectureScene: (props: ArchitectureSceneProps) =>