Merge pull request #2068 from DeusData/feat/codeatlas-web

CBM Lens Beta: graph exploration and architecture workspace
This commit is contained in:
Martin Vogel
2026-10-12 05:38:30 +02:00
committed by GitHub
1152 changed files with 374270 additions and 9936 deletions
+6 -2
View File
@@ -21,9 +21,13 @@ 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
+22
View File
@@ -301,6 +301,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
+22 -9
View File
@@ -448,13 +448,13 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp
SQLITE_WRITER_SRC = $(CBM_DIR)/sqlite_writer.c
# Store module (new)
STORE_SRCS = src/store/store.c src/store/store_impact.c src/store/store_graph_digest.c src/store/store_content_digest.c
STORE_SRCS = src/store/store.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
@@ -565,7 +565,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
@@ -574,7 +574,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
@@ -732,6 +741,7 @@ TEST_STORE_SRCS = \
tests/test_store_graph_digest.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 \
@@ -836,7 +846,7 @@ TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/
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
@@ -982,7 +992,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) ─
@@ -1141,13 +1151,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.
@@ -1155,7 +1165,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)
@@ -1327,8 +1337,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
+18 -3
View File
@@ -35,7 +35,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si
- **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.
- **CBM Atlas** — the built-in human interface at `localhost:9749`: a reading IDE over the index (explorer and read-only Monaco reader, a semantic twin that follows the caret, flow, bug-hunt and change-scope reading modes, the 3D galaxy, source-cited answers, project management), 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.
@@ -131,9 +131,9 @@ Package-manager setup (npm, PyPI, or Go) verifies and publishes a coherent priva
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 — the human interface
The graph UI is built into the binary — every install on every channel has it. Then run it:
The MCP tools are the agent's interface to the code graph; **CBM Lens Beta** is the human's. It is built into the binary — every install on every channel has it:
```bash
codebase-memory-mcp --ui=true --port=9749
@@ -141,6 +141,21 @@ codebase-memory-mcp --ui=true --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.
CBM Lens builds on **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess.
When something in CBM Lens does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file.
- **Explorer and reader** — the indexed file tree and a read-only Monaco reader whose source comes from the index.
- **Semantic twin** — follows the caret: what the symbol under it holds, calls, raises and touches, as facts or as pseudocode, pitched at who is reading.
- **Reading modes** — `[w]hy am I here` picks a way in (hunt a bug, scope a change, understand the project, pick an entry point); **flow** walks the calls in the order they run; **bug hunt** compares the expected path into a symbol with what `ingest_traces` observed; **change scope** shows what a change reaches and what covers it.
- **Galaxy** — the persistent 3D graph with focus following in both directions, and a hierarchy view of the entry subgraph.
- **Command line** — search by meaning while you type; a line ending in `?` builds a source-cited answer from indexed fact cards, with an optional local-model wording pass (a llama-server sidecar you start yourself; off by default, and no model or binary ships).
- **Live agents** — an opt-in overlay of AI agents working in the same repository, fed by a loopback bridge over tool-hook events (`graph-ui/agents/`).
- **`[p]rojects`** — index a repository, check or remove an index, edit the project's decision record, and see the server's processes and log.
- **`[?]help`** — what it reads, what it cannot do, and every key it listens to, derived from the wiring itself.
Open a project with `?project=<name>` in the address, or pick it in the projects panel. The frontend's own README, plan, honesty rules and recorded proof runs live under `graph-ui/`.
### Auto-Index
Enable automatic indexing on MCP session start:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 279 KiB

+60
View File
@@ -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
-48
View File
@@ -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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
-64
View File
@@ -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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-92
View File
@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
-32
View File
@@ -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<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"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",
className
)}
{...props}
/>
)
}
export { Input }
-56
View File
@@ -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<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
-26
View File
@@ -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<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+73
View File
@@ -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.
+132
View File
@@ -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 `<cache>/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.
+149
View File
@@ -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 <tituswormer@gmail.com>
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.
```
@@ -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 <verzeichnis>
*
* 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`);
+256
View File
@@ -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())
+218
View File
@@ -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()
-1
View File
@@ -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"}
+70
View File
@@ -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.
+357
View File
@@ -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
}
]
+63
View File
@@ -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`.
+44
View File
@@ -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.
+25
View File
@@ -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.
@@ -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.
@@ -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"}
@@ -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"}
@@ -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.
@@ -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"
}
@@ -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);
}
@@ -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));
}
@@ -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);
}
@@ -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"]
}
@@ -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.
@@ -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"
}
@@ -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';
}
@@ -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<string, unknown>;
}
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<string, unknown>): 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;
}
@@ -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 });
});
}
@@ -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 });
});
}
@@ -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();
@@ -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 };
}
@@ -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);
}
@@ -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<string, string>;
query: Record<string, string>;
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}>`;
}
}
@@ -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<UserInput> | 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;
}
@@ -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);
});
@@ -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"]
}
+5 -3
View File
@@ -1,11 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codebase Memory — Graph</title>
<meta name="color-scheme" content="dark" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3E%3Crect width=%2716%27 height=%2716%27 fill=%27%230a0e0d%27/%3E%3Ccircle cx=%278%27 cy=%278%27 r=%274%27 fill=%27%2333ff99%27/%3E%3C/svg%3E" />
<title>CBM Lens Beta</title>
</head>
<body class="bg-[#0a0a10] text-[#e4e4ed] min-h-screen">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+134
View File
@@ -0,0 +1,134 @@
#!/bin/sh
# Ein Modell von Hugging Face in das Cache-Verzeichnis dieses Projekts holen.
#
# Aufruf:
# llm/fetch-model.sh <user>/<repo>[:<quant>] holt das Modell
# llm/fetch-model.sh --print <user>/<repo> 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 <user>/<repo>[:<quant>]" >&2
echo " llm/fetch-model.sh --print <user>/<repo>[:<quant>]" >&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
+280
View File
@@ -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 <repo>/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 <pfad>.gguf eine bestimmte Datei
# llm/start.sh <name> 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 <repo>/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"
+54
View File
@@ -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)."
+7
View File
@@ -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
+2797 -2868
View File
File diff suppressed because it is too large Load Diff
+78 -36
View File
@@ -1,47 +1,89 @@
{
"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"
"@huggingface/transformers": {
"sharp": "0.35.0"
},
"onnxruntime-node@1.24.3": {
"adm-zip": "0.6.0"
}
},
"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",
"three": "0.183.2",
"yaml": "2.8.2"
}
}
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>CodeAtlasWeb W0 Spike: Serverquelle in read-only Monaco</title>
<!--
W0-Spike. Wegwerf-Beweiscode, NICHT die Produktarchitektur.
Zweck: zeigen, dass der gebaute C-Server (PR 1860) echten Quelltext über
/rpc get_code_snippet liefert und Monaco ihn read-only mit genau einer
Decoration an der Call-Site rendert. Kein CDN: Monaco kommt aus
/node_modules/monaco-editor/min/vs.
-->
<style>
:root {
--atlas-bg: #06090b;
--atlas-panel: #0b1013;
--atlas-line: #16211f;
--atlas-phosphor: #33ff99;
--atlas-dim: #7d9a8e;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
background: var(--atlas-bg);
color: var(--atlas-phosphor);
font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
}
body {
display: flex;
flex-direction: column;
}
header {
flex: 0 0 auto;
padding: 10px 16px;
border-bottom: 1px solid var(--atlas-line);
background: var(--atlas-panel);
font-size: 12px;
letter-spacing: 0.04em;
display: flex;
gap: 18px;
align-items: baseline;
flex-wrap: wrap;
}
header .brand { color: var(--atlas-phosphor); font-weight: 700; }
header .meta { color: var(--atlas-dim); }
header .meta b { color: var(--atlas-phosphor); font-weight: 500; }
#editor {
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
#status {
flex: 0 0 auto;
padding: 8px 16px;
border-top: 1px solid var(--atlas-line);
background: var(--atlas-panel);
color: var(--atlas-dim);
font-size: 12px;
}
#status.failed { color: #ff6b6b; }
/* Die eine Decoration an der Call-Site: Zeilenlicht plus Badge "1". */
.atlas-callsite-line {
background: rgba(51, 255, 153, 0.10);
box-shadow: inset 2px 0 0 0 var(--atlas-phosphor);
}
.atlas-callsite-glyph {
position: relative;
}
.atlas-callsite-glyph::after {
content: "1";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--atlas-phosphor);
color: var(--atlas-bg);
font: 700 10px/15px "SF Mono", Menlo, Consolas, monospace;
text-align: center;
}
</style>
</head>
<body>
<header>
<span class="brand">CODEATLAS W0 SPIKE</span>
<span class="meta">quelle <b id="hdr-source">/rpc get_code_snippet</b></span>
<span class="meta">symbol <b id="hdr-qn">...</b></span>
<span class="meta">datei <b id="hdr-file">...</b></span>
<span class="meta">modus <b id="hdr-mode">read-only</b></span>
</header>
<div id="editor"></div>
<div id="status">lade ...</div>
<script src="/node_modules/monaco-editor/min/vs/loader.js"></script>
<script src="/spike/spike.js"></script>
</body>
</html>
+210
View File
@@ -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();
}
})();
-59
View File
@@ -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<typeof import("./lib/i18n")>();
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(<App />);
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(<App />);
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(<App />);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
});
});
+5020 -146
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
/*
* Review of K42 ("other Refresh buttons the same way"): Refresh in ADR read
* the decision record again and nothing on screen changed (measured in the
* browser: no visible state for a single frame). It now says that it runs and
* then when it ran and whether anything changed, as the Architecture
* Refresh buttons do.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import AdrWorkspace from './AdrWorkspace';
import type { AdrRecord } from '../projects/projects-model';
import { clearAdrDraft } from './adr-model';
let host: HTMLDivElement, root: Root;
const project = 'adr-refresh-test';
const record = (content: string): AdrRecord => ({ hasAdr: true, content, updatedAt: '2026-09-14T12:00:00Z' });
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
clearAdrDraft(project);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); clearAdrDraft(project); });
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
const refresh = () => [...host.querySelectorAll('.adr-actions button')][0] as HTMLButtonElement;
const status = () => host.querySelector('.adr-actions .atlas-refresh-status')?.textContent;
const TIME = '\\d\\d:\\d\\d:\\d\\d';
it('K42: Refresh in ADR says that it runs, then when it ran and whether the record changed, or that it failed', async () => {
let next = deferred<AdrRecord>();
const adr = vi.fn().mockResolvedValueOnce(record('# Decisions')).mockImplementation(() => next.promise);
await act(async () => root.render(<AdrWorkspace project={project} active source={{ adr, saveAdr: vi.fn() }} />));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toBe('');
await act(async () => refresh().click());
expect(refresh().textContent).toBe('Refreshing…');
expect(refresh().getAttribute('aria-disabled')).toBe('true');
await act(async () => next.resolve(record('# Decisions')));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
next = deferred<AdrRecord>();
await act(async () => refresh().click());
await act(async () => next.resolve(record('# Decisions\n\n## Storage')));
expect(status()).toMatch(new RegExp(`^Decisions refreshed at ${TIME}$`));
next = deferred<AdrRecord>();
await act(async () => refresh().click());
await act(async () => next.reject(new Error('offline')));
// The alert below the toolbar says what failed and offers Try again; the status beside the button only says when.
expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`));
expect(host.querySelector('.adr-notice[role="alert"]')?.textContent).toContain('Could not load the decision record.');
});
+344
View File
@@ -0,0 +1,344 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import AdrWorkspace, { type AdrWorkspaceProps } from './AdrWorkspace';
import type { AdrRecord } from '../projects/projects-model';
import { clearAdrDraft, readAdrDraft } from './adr-model';
let host: HTMLDivElement;
let root: Root;
const record = (content = '# Decisions\n\n## Storage\n\nUse a local database.'): AdrRecord => ({ hasAdr: true, content, updatedAt: '2026-09-14T12:00:00Z' });
const empty: AdrRecord = { hasAdr: false, content: '', updatedAt: '' };
const projects = ['adr-test-alpha', 'adr-test-beta', 'adr-test-restored'];
const project = projects[0];
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
for (const name of projects) clearAdrDraft(name);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove();
for (const name of projects) clearAdrDraft(name);
vi.restoreAllMocks();
});
function source(initial = record()) {
let current = initial;
return {
adr: vi.fn(async (_project: string) => ({ ...current })),
saveAdr: vi.fn(async (_project: string, content: string) => { current = record(content); }),
replace(next: AdrRecord) { current = next; },
};
}
async function render(api: AdrWorkspaceProps['source'], overrides: Partial<AdrWorkspaceProps> = {}) {
await act(async () => root.render(<AdrWorkspace project={project} active source={api} {...overrides} />));
}
function button(name: string): HTMLButtonElement {
const found = [...host.querySelectorAll('button')].find(candidate => candidate.textContent === name);
if (!found) throw new Error(`Missing button: ${name}`);
return found;
}
async function click(name: string) { await act(async () => button(name).click()); }
async function edit(content: string) {
if (!host.querySelector('textarea')) await click('Edit');
await act(async () => {
const input = host.querySelector('textarea')!;
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(input, content);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
it('loads only while active and refreshes the same project when a clean reader is reopened', async () => {
const api = source();
await render(api, { active: false });
expect(api.adr).not.toHaveBeenCalled();
await render(api);
expect(api.adr).toHaveBeenCalledWith(project);
expect(host.querySelector('article')?.textContent).toContain('Use a local database.');
await render(api, { active: false });
api.replace(record('# New decision'));
await render(api);
expect(api.adr).toHaveBeenCalledTimes(2);
expect(host.querySelector('article')?.textContent).toBe('New decision');
});
it('preflights and verifies an explicit save; editing never writes automatically', async () => {
const api = source();
await render(api);
await edit('# Decisions\n\nUse SQLite.');
expect(api.saveAdr).not.toHaveBeenCalled();
expect(readAdrDraft(project)?.content).toContain('Use SQLite.');
await click('Save');
expect(api.adr).toHaveBeenCalledTimes(3);
expect(api.saveAdr).toHaveBeenCalledExactlyOnceWith(project, '# Decisions\n\nUse SQLite.');
expect(host.querySelector('textarea')).toBeNull();
expect(host.querySelector('[role="status"]')?.textContent).toBe('Saved');
expect(readAdrDraft(project)).toBeUndefined();
});
it('retains dirty edits when hidden, switched to another project, and remounted', async () => {
const api = source();
await render(api);
await edit('# My unsaved choice');
await render(api, { active: false });
await render(api);
expect(api.adr).toHaveBeenCalledTimes(1);
expect(host.querySelector('textarea')?.value).toBe('# My unsaved choice');
await render(api, { project: projects[1] });
expect(host.querySelector('textarea')).toBeNull();
await render(api);
expect(host.querySelector('textarea')?.value).toBe('# My unsaved choice');
expect(host.querySelector('strong')?.textContent).toBe(project);
await act(async () => root.unmount());
root = createRoot(host);
await render(api);
expect(host.querySelector('textarea')?.value).toBe('# My unsaved choice');
expect(api.saveAdr).not.toHaveBeenCalled();
});
it('restores a session-stored draft and its base after reload, then catches an intervening change', async () => {
const name = projects[2];
sessionStorage.setItem(`cbm.adr.draft:${encodeURIComponent(name)}`, JSON.stringify({ version: 1, base: record(), content: '# Restored draft' }));
const api = source(record('# Updated elsewhere'));
await render(api, { project: name });
expect(host.querySelector('textarea')?.value).toBe('# Restored draft');
expect(host.textContent).toContain('The server document changed.');
expect(button('Save').disabled).toBe(true);
expect(readAdrDraft(name)?.base.content).toBe(record().content);
});
it('does not replace a new project with the previous project’s late read', async () => {
const pending = deferred<AdrRecord>();
const api = source();
api.adr.mockImplementationOnce(() => pending.promise);
await render(api);
await render(api, { project: projects[1] });
await act(async () => pending.resolve(record('# Wrong old project')));
expect(host.querySelector('strong')?.textContent).toBe(projects[1]);
expect(host.textContent).not.toContain('Wrong old project');
});
it('shows a retry on read failure and prevents editing or saving an unread record', async () => {
const api = source();
api.adr.mockRejectedValueOnce(new Error('offline'));
await render(api);
expect(host.textContent).toContain('Could not load the decision record.');
expect(host.querySelector('textarea')).toBeNull();
expect([...host.querySelectorAll('button')].map(value => value.textContent)).not.toContain('Save');
await click('Try again');
await edit('# Recovered');
expect(button('Save').disabled).toBe(false);
});
it('keeps the draft through a busy save and succeeds on an explicit retry', async () => {
const api = source();
api.saveAdr.mockRejectedValueOnce({ status: 423 });
await render(api);
await edit('# Pending decision');
await click('Save');
expect(host.textContent).toContain('The project is busy.');
expect(host.querySelector('textarea')?.value).toBe('# Pending decision');
expect(readAdrDraft(project)?.content).toBe('# Pending decision');
expect(host.querySelector('[role="status"]')?.textContent).not.toBe('Saved');
await click('Save');
expect(host.querySelector('[role="status"]')?.textContent).toBe('Saved');
});
it('does not write when its preflight read fails', async () => {
const api = source();
await render(api);
await edit('# Keep until the server is readable');
api.adr.mockRejectedValueOnce(new Error('offline'));
await click('Save');
expect(api.saveAdr).not.toHaveBeenCalled();
expect(host.textContent).toContain('Could not check the latest version.');
expect(readAdrDraft(project)?.content).toBe('# Keep until the server is readable');
});
it('does not refresh a dirty draft on reentry after an earlier read retry', async () => {
const api = source();
api.adr.mockRejectedValueOnce(new Error('offline'));
await render(api);
await click('Try again');
await edit('# Keep my draft');
await render(api, { active: false });
await render(api);
expect(api.adr).toHaveBeenCalledTimes(2);
expect(host.querySelector('textarea')?.value).toBe('# Keep my draft');
});
it('keeps a draft across project switches when session storage is unavailable', async () => {
const api = source();
await render(api);
// Node's Web Storage can survive jsdom global setup; target the storage actually used.
const write = vi.spyOn(Object.getPrototypeOf(sessionStorage), 'setItem')
.mockImplementation(() => { throw new Error('storage unavailable'); });
await edit('# In-memory draft');
expect(write).toHaveBeenCalled();
expect(host.textContent).toContain('browser storage is unavailable');
await render(api, { project: projects[1] });
await render(api);
expect(host.querySelector('textarea')?.value).toBe('# In-memory draft');
expect(host.textContent).toContain('browser storage is unavailable');
});
it('blocks conflicting writes until the latest document has been explicitly reviewed', async () => {
const api = source();
await render(api);
await edit('# My change');
api.replace(record('# Another writer\n\nPreserve this decision.'));
await click('Save');
expect(api.saveAdr).not.toHaveBeenCalled();
expect(button('Save').disabled).toBe(true);
expect(host.querySelector('textarea')?.value).toBe('# My change');
expect([...host.querySelectorAll('button')].map(value => value.textContent)).not.toContain('Use latest as edit base');
await click('Review latest version');
expect(host.querySelector('[aria-label="Latest server version"]')?.textContent).toContain('Preserve this decision.');
await click('Use latest as edit base');
await edit('# Another writer\n\nPreserve this decision.\n\n## My change');
await click('Save');
expect(api.saveAdr).toHaveBeenCalledExactlyOnceWith(project, '# Another writer\n\nPreserve this decision.\n\n## My change');
});
it('can discard its conflicting draft and load the reviewed server document', async () => {
const api = source();
await render(api);
await edit('# My change');
api.replace(record('# New server record'));
await click('Save');
await click('Review latest version');
await click('Discard draft and load latest');
expect(host.querySelector('textarea')).toBeNull();
expect(host.querySelector('article')?.textContent).toBe('New server record');
expect(readAdrDraft(project)).toBeUndefined();
expect(api.saveAdr).not.toHaveBeenCalled();
});
it('does not report Saved after a verification failure and retains the draft for retry', async () => {
const api = source();
await render(api);
await edit('# Awaiting verification');
api.adr.mockResolvedValueOnce(record()).mockRejectedValueOnce(new Error('read-back failed'));
await click('Save');
expect(host.textContent).toContain('The save could not be verified.');
expect(readAdrDraft(project)?.content).toBe('# Awaiting verification');
expect(host.querySelector('[role="status"]')?.textContent).not.toBe('Saved');
await click('Save');
expect(api.saveAdr).toHaveBeenCalledTimes(1);
expect(host.querySelector('[role="status"]')?.textContent).toBe('Saved');
});
it('keeps a draft if the verified server content differs from what was submitted', async () => {
const api = source();
await render(api);
await edit('# Submitted');
api.adr.mockResolvedValueOnce(record()).mockResolvedValueOnce(record('# Concurrent replacement'));
await click('Save');
expect(host.textContent).toContain('The server returned different content after saving.');
expect(host.querySelector('textarea')?.value).toBe('# Submitted');
expect(button('Save').disabled).toBe(true);
expect(readAdrDraft(project)?.content).toBe('# Submitted');
});
it('disables edits during a save and ignores its completion after a project switch', async () => {
const pending = deferred<void>();
const api = source();
api.saveAdr.mockImplementationOnce(() => pending.promise);
await render(api);
await edit('# Original project draft');
await click('Save');
expect(host.querySelector('textarea')?.disabled).toBe(true);
expect(button('Cancel').disabled).toBe(true);
await render(api, { project: projects[1] });
await act(async () => pending.resolve());
expect(host.querySelector('strong')?.textContent).toBe(projects[1]);
expect(host.querySelector('[role="status"]')?.textContent).not.toBe('Saved');
expect(readAdrDraft(project)?.content).toBe('# Original project draft');
});
it('enforces UTF-8 content and complete JSON request limits', async () => {
const api = source();
await render(api);
await edit('🧭'.repeat(2001));
expect(button('Save').disabled).toBe(true);
expect(host.textContent).toContain('8,004 / 8,000 bytes');
await edit('\u0001'.repeat(3000));
expect(button('Save').disabled).toBe(true);
expect(host.textContent).toContain('The encoded save request is too large.');
await edit('a'.repeat(8000));
expect(button('Save').disabled).toBe(false);
});
it('renders Markdown safely and builds a navigable outline from actual headings', async () => {
const api = source(record('# Decisions\n\n## Same title\n\n| Choice | Reason |\n| --- | --- |\n| A | B |\n\n## Same title\n\n```js\nrun();\n```\n\n[unsafe](javascript:alert(1))\n\n<strong>Raw HTML stays text</strong>\n\n![remote](https://example.com/tracker.png)'));
const scroll = vi.fn();
const original = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scroll;
try {
await render(api);
expect(host.querySelector('table')).not.toBeNull();
expect(host.querySelector('pre code')?.textContent).toBe('run();\n');
expect(host.querySelector('article strong, img, a[href^="javascript:"]')).toBeNull();
const nav = host.querySelector('[aria-label="On this page"]')!;
expect([...nav.querySelectorAll('button')].map(value => value.textContent)).toEqual(['Same title', 'Same title']);
const headings = [...host.querySelectorAll('article h1, article h2')];
expect(new Set(headings.map(value => value.id)).size).toBe(3);
await act(async () => nav.querySelectorAll('button')[1].click());
expect(scroll).toHaveBeenCalledOnce();
expect(document.activeElement).toBe(headings[2]);
} finally { HTMLElement.prototype.scrollIntoView = original; }
});
it('does not reserve an outline column for documents without headings', async () => {
await render(source(record('A short decision without a heading.')));
expect(host.querySelector('[aria-label="On this page"]')).toBeNull();
expect(host.querySelector('.adr-body')?.getAttribute('data-outline')).toBe('false');
});
it('creates a generic decision scaffold without saving until requested and cancels explicitly', async () => {
const api = source(empty);
await render(api);
await click('Create decision record');
const text = host.querySelector('textarea')?.value ?? '';
expect(text).toContain('### Context');
expect(text).toContain('### Alternatives');
expect(text).not.toContain(project);
expect(api.saveAdr).not.toHaveBeenCalled();
await click('Preview');
expect(host.querySelector('article h1')?.textContent).toBe('Architecture decisions');
await click('Cancel');
expect(readAdrDraft(project)).toBeUndefined();
expect(host.querySelector('textarea')).toBeNull();
});
it('warns before unloading only while a draft is dirty', async () => {
const api = source();
await render(api);
let event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
await edit('# Draft');
event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await click('Cancel');
event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
it('does not request or offer writes without a selected project', async () => {
const api = source();
await render(api, { project: '' });
expect(api.adr).not.toHaveBeenCalled();
expect(host.textContent).toContain('Select a project');
expect(host.querySelector('button')).toBeNull();
});
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useId, useRef, useState } from 'react';
import type { JSX } from 'react';
import ChatMarkdown from '../browser-ai/ChatMarkdown';
import type { ProjectsSource } from '../projects/ProjectsPanel';
import type { AdrRecord } from '../projects/projects-model';
import { ADR_CONTENT_LIMIT, adrSize, appendDecision, clearAdrDraft, readAdrDraft, sameAdr, storeAdrDraft } from './adr-model';
import { adrStrings as s } from './adr-strings';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import './adr.css';
export interface AdrWorkspaceProps {
project: string;
active: boolean;
source: Pick<ProjectsSource, 'adr' | 'saveAdr'>;
}
function busyError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'status' in error && error.status === 423;
}
interface EditorState {
base?: AdrRecord;
content: string;
editing: boolean;
preview: boolean;
phase: 'idle' | 'loading' | 'ready' | 'error';
error: string;
notice: string;
conflict?: AdrRecord;
reviewing: boolean;
/** How many readings of the record started; the Refresh status speaks about one of them (review of K42). */
reads: number;
}
function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JSX.Element {
const [state, setState] = useState<EditorState>(() => {
const draft = readAdrDraft(project);
return { base: draft?.base, content: draft?.content ?? '', editing: !!draft, preview: false,
phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false, reads: 0 };
});
const [saving, setSaving] = useState(false);
const [storageAvailable, setStorageAvailable] = useState(true);
const [reload, setReload] = useState(0);
const stateRef = useRef(state);
stateRef.current = state;
const mounted = useRef(true);
const savingRef = useRef(false);
const loadTicket = useRef(0);
const lastReload = useRef(0);
const hasLoaded = useRef(false);
const editor = useRef<HTMLTextAreaElement>(null);
const document = useRef<HTMLDivElement>(null);
const headingPrefix = useId();
const [outline, setOutline] = useState<{ id: string; label: string; level: number }[]>([]);
const dirty = !!state.base && state.content !== state.base.content;
const size = adrSize(project, state.content);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; loadTicket.current += 1; }; }, []);
useEffect(() => {
const current = stateRef.current;
const forced = reload !== lastReload.current;
lastReload.current = reload;
if (!active || savingRef.current || (hasLoaded.current && current.phase === 'ready' && current.base && current.content !== current.base.content && !forced)) return;
const ticket = ++loadTicket.current;
setState(value => ({ ...value, phase: 'loading', error: '', reads: value.reads + 1 }));
void source.adr(project).then(record => {
if (!mounted.current || ticket !== loadTicket.current) return;
hasLoaded.current = true;
setState(value => {
if (value.base && value.content !== value.base.content) {
return { ...value, phase: 'ready', error: '', conflict: sameAdr(value.base, record) ? undefined : record };
}
return { ...value, base: record, content: record.content, phase: 'ready', error: '', conflict: undefined };
});
}, error => {
if (mounted.current && ticket === loadTicket.current) setState(value => ({ ...value, phase: 'error', error: busyError(error) ? s.busy : s.readFailed }));
});
return () => { if (ticket === loadTicket.current) loadTicket.current += 1; };
}, [active, project, source, reload]);
useEffect(() => {
if (state.base && dirty) setStorageAvailable(storeAdrDraft(project, { version: 1, base: state.base, content: state.content }));
else if (state.base) clearAdrDraft(project);
}, [project, state.base, state.content, dirty]);
useEffect(() => {
if (!dirty) return;
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
useEffect(() => {
const headings = [...(document.current?.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6') ?? [])];
const entries = headings.map((heading, index) => {
heading.id = `${headingPrefix}-heading-${index}`;
heading.tabIndex = -1;
return { id: heading.id, label: heading.textContent ?? '', level: Number(heading.tagName.slice(1)) };
});
const level = entries.some(heading => heading.level === 2) ? 2 : Math.min(...entries.map(heading => heading.level));
setOutline(entries.filter(heading => heading.level === level));
}, [state.content, state.editing, state.preview, state.phase, headingPrefix]);
useEffect(() => { if (state.editing && active) editor.current?.focus(); }, [state.editing, active]);
const acceptSaved = (record: AdrRecord): void => {
clearAdrDraft(project);
setState(value => ({ ...value, base: record, content: record.content, phase: 'ready', editing: false,
error: '', notice: s.saved, conflict: undefined, reviewing: false }));
};
const save = async (): Promise<void> => {
const current = stateRef.current;
if (savingRef.current || current.phase !== 'ready' || !current.base || current.conflict
|| current.content === current.base.content || !adrSize(project, current.content).valid) return;
savingRef.current = true;
loadTicket.current += 1;
setSaving(true);
setState(value => ({ ...value, error: '', notice: '' }));
let stage: 'check' | 'write' | 'verify' = 'check';
try {
const latest = await source.adr(project);
if (!mounted.current) return;
if (!sameAdr(current.base, latest)) {
if (latest.content === current.content && (latest.hasAdr || current.content === '')) acceptSaved(latest);
else setState(value => ({ ...value, conflict: latest, reviewing: false }));
return;
}
stage = 'write';
await source.saveAdr(project, current.content);
if (!mounted.current) return;
stage = 'verify';
const verified = await source.adr(project);
if (!mounted.current) return;
if (verified.content !== current.content || (!verified.hasAdr && current.content !== '')) {
setState(value => ({ ...value, conflict: verified, reviewing: false, error: s.mismatch }));
return;
}
acceptSaved(verified);
} catch (error) {
if (mounted.current) setState(value => ({ ...value, error: busyError(error) ? s.busy
: stage === 'check' ? s.checkFailed : stage === 'verify' ? s.verifyFailed : s.saveFailed }));
} finally {
savingRef.current = false;
if (mounted.current) setSaving(false);
}
};
const startEditing = (append = false): void => setState(value => ({ ...value, editing: true,
content: append ? appendDecision(value.content) : value.content, error: '', notice: '' }));
const cancel = (): void => {
clearAdrDraft(project);
setState(value => ({ ...value, base: value.conflict ?? value.base, content: (value.conflict ?? value.base)?.content ?? '', editing: false,
preview: false, conflict: undefined, reviewing: false, error: '', notice: '' }));
};
const useLatest = (discard: boolean): void => setState(value => value.conflict ? ({ ...value,
base: value.conflict, content: discard ? value.conflict.content : value.content,
editing: !discard, conflict: undefined, reviewing: false, phase: 'ready', error: '', notice: discard ? '' : s.rebased }) : value);
const ready = state.phase === 'ready';
const documentVisible = !state.editing || state.preview;
/*
* Review of K42: Refresh read the record again and nothing on screen
* changed. Now it says that it runs, then when and whether the record
* changed; a failure only gets its time here, the alert below says what
* failed and offers Try again.
*/
const refresh = useRefreshFeedback({ key: String(state.reads), settled: state.phase === 'ready' || state.phase === 'error',
value: state.base && { content: state.base.content, updatedAt: state.base.updatedAt, hasAdr: state.base.hasAdr }, error: state.phase === 'error' ? '' : undefined });
return <section className="adr-workspace" aria-label={s.label} aria-busy={state.phase === 'loading' || saving}>
<header className="adr-toolbar">
<div className="adr-project"><strong>{project}</strong><span role="status">{saving ? s.saving : dirty ? s.draft : state.notice || (state.base?.updatedAt ? s.edited : s.noDate)}</span>
{!dirty && !state.notice && state.base?.updatedAt && <time dateTime={state.base.updatedAt}>{state.base.updatedAt}</time>}</div>
<div className="adr-actions">{state.editing ? <>
<button type="button" aria-pressed={state.preview} disabled={saving} onClick={() => setState(value => ({ ...value, preview: !value.preview }))}>{state.preview ? s.hidePreview : s.preview}</button>
<button type="button" disabled={saving} onClick={cancel}>{s.cancel}</button>
<button type="button" className="adr-primary" disabled={!ready || !dirty || !size.valid || saving || !!state.conflict} onClick={() => { void save(); }}>{saving ? s.saving : s.save}</button>
</> : <>
<RefreshControl labels={s.refreshFeedback} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); setReload(value => value + 1); }} />
{ready && state.base?.hasAdr && <button type="button" onClick={() => startEditing()}>{s.edit}</button>}
{ready && state.base?.hasAdr && <button type="button" className="adr-primary" onClick={() => startEditing(true)}>{s.add}</button>}
</>}</div>
</header>
{state.error && <div className="adr-notice" role="alert"><span>{state.error}</span>
{state.phase === 'error' && <button type="button" onClick={() => setReload(value => value + 1)}>{s.retry}</button>}</div>}
{dirty && state.notice && <p className="adr-notice" role="status">{state.notice}</p>}
{dirty && !storageAvailable && <p className="adr-notice" role="status">{s.storageUnavailable}</p>}
{state.conflict && <div className="adr-conflict">
<div className="adr-notice" role="alert"><span>{s.conflict}</span><button type="button" disabled={saving}
aria-expanded={state.reviewing} onClick={() => setState(value => ({ ...value, reviewing: !value.reviewing }))}>{state.reviewing ? s.hideReview : s.review}</button></div>
{state.reviewing && <><section className="adr-latest" aria-label={s.latest}>
<p className="adr-section-label">{s.latest}</p>{state.conflict.content ? <ChatMarkdown text={state.conflict.content} /> : <p>{s.latestEmpty}</p>}
</section><div className="adr-conflict-actions"><button type="button" onClick={() => useLatest(false)}>{s.rebase}</button>
<button type="button" onClick={() => useLatest(true)}>{s.reload}</button></div></>}
</div>}
{state.phase === 'loading' && !state.base && <p className="adr-empty" role="status">{s.loading}</p>}
{state.editing && <div className="adr-edit-info"><span className={size.valid ? '' : 'adr-limit-exceeded'}>{s.size(size.bytes)}</span>
{!size.valid && <span role="alert">{size.bytes > ADR_CONTENT_LIMIT ? s.sizeExceeded : s.bodyExceeded}</span>}</div>}
{state.base && (state.editing || state.base.hasAdr) && <div className="adr-body" data-editing={state.editing} data-preview={state.preview} data-outline={outline.length > 0 && documentVisible}>
{state.editing && <textarea ref={editor} className="adr-editor" aria-label={s.editor} spellCheck={false} disabled={saving}
value={state.content} onChange={event => setState(value => ({ ...value, content: event.target.value, error: '', notice: '' }))} />}
{documentVisible && <>
<div ref={document} className="adr-document-area" aria-label={s.document}><article className="adr-document"><ChatMarkdown text={state.content} /></article></div>
{outline.length > 0 && <nav className="adr-outline" aria-label={s.outline}><p>{s.outline}</p>{outline.map(heading => <button
key={heading.id} type="button" data-level={heading.level} onClick={() => {
const node = document.current?.querySelector<HTMLElement>(`[id="${heading.id}"]`);
node?.scrollIntoView({ block: 'start', behavior: 'smooth' }); node?.focus({ preventScroll: true });
}}>{heading.label}</button>)}</nav>}
</>}
</div>}
{ready && !state.editing && !state.base?.hasAdr && <div className="adr-empty"><h2>{s.emptyTitle}</h2><p>{s.emptyBody}</p>
<button type="button" className="adr-primary" onClick={() => startEditing(true)}>{s.create}</button></div>}
</section>;
}
export default function AdrWorkspace(props: AdrWorkspaceProps): JSX.Element {
return props.project ? <ProjectAdrWorkspace key={props.project} {...props} />
: <section className="adr-workspace" aria-label={s.label}><p className="adr-empty">{s.noProject}</p></section>;
}
+61
View File
@@ -0,0 +1,61 @@
import type { AdrRecord } from '../projects/projects-model';
export const ADR_CONTENT_LIMIT = 8000;
export const ADR_BODY_LIMIT = 16_384;
export interface AdrDraft {
version: 1;
base: AdrRecord;
content: string;
}
const memoryDrafts = new Map<string, AdrDraft>();
const draftKey = (project: string) => `cbm.adr.draft:${encodeURIComponent(project)}`;
export function sameAdr(left: AdrRecord, right: AdrRecord): boolean {
return left.hasAdr === right.hasAdr && left.content === right.content;
}
export function adrSize(project: string, content: string) {
const encoder = new TextEncoder();
const bytes = encoder.encode(content).length;
const bodyBytes = encoder.encode(JSON.stringify({ project, content })).length;
return { bytes, bodyBytes, valid: bytes <= ADR_CONTENT_LIMIT && bodyBytes <= ADR_BODY_LIMIT };
}
export function readAdrDraft(project: string): AdrDraft | undefined {
const key = draftKey(project);
const inMemory = memoryDrafts.get(key);
if (inMemory) return inMemory;
try {
const value: unknown = JSON.parse(sessionStorage.getItem(key) ?? 'null');
if (!value || typeof value !== 'object') return;
const draft = value as Partial<AdrDraft>;
if (draft.version !== 1 || typeof draft.content !== 'string' || !draft.base
|| typeof draft.base.hasAdr !== 'boolean' || typeof draft.base.content !== 'string'
|| typeof draft.base.updatedAt !== 'string') return;
if (draft.content === draft.base.content) return;
const restored: AdrDraft = { version: 1, content: draft.content, base: { ...draft.base } };
memoryDrafts.set(key, restored);
return restored;
} catch { return; }
}
/** Memory also retains drafts across project switches when browser storage is unavailable. */
export function storeAdrDraft(project: string, draft: AdrDraft): boolean {
const key = draftKey(project);
memoryDrafts.set(key, draft);
try { sessionStorage.setItem(key, JSON.stringify(draft)); return true; } catch { return false; }
}
export function clearAdrDraft(project: string): void {
const key = draftKey(project);
memoryDrafts.delete(key);
try { sessionStorage.removeItem(key); } catch { /* Storage can be unavailable. */ }
}
/** Generic authoring scaffold; no repository facts are invented. */
export function appendDecision(content: string): string {
const heading = content.trim() ? '\n\n---\n\n## Decision title' : '# Architecture decisions\n\n## Decision title';
return `${content.trimEnd()}${heading}\n\n**Status:** Proposed\n\n### Context\n\nWhat problem or constraint led to this decision?\n\n### Decision\n\nWhat will change, and why?\n\n### Alternatives\n\nWhich options were considered?\n\n### Consequences\n\nWhat benefits, costs, and follow-up work should readers know?\n`;
}
+44
View File
@@ -0,0 +1,44 @@
export const adrStrings = {
label: 'Architecture decisions',
noProject: 'Select a project to read its architecture decisions.',
loading: 'Loading decisions…',
/** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */
refreshFeedback: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Decisions refreshed at ${time}` },
retry: 'Try again',
edit: 'Edit',
add: 'Add decision',
create: 'Create decision record',
emptyTitle: 'Record the decisions behind this project.',
emptyBody: 'Keep context, alternatives, and consequences together in a Markdown document shared with your agents.',
save: 'Save',
saving: 'Saving…',
saved: 'Saved',
cancel: 'Cancel',
preview: 'Preview',
hidePreview: 'Hide preview',
editor: 'Decision record Markdown',
document: 'Decision document',
outline: 'On this page',
draft: 'Unsaved draft',
restored: 'Draft restored. Changes are saved only when you choose Save.',
edited: 'Updated',
noDate: 'Markdown document',
readFailed: 'Could not load the decision record. Try again.',
saveFailed: 'Could not save. Your draft is intact; try again.',
checkFailed: 'Could not check the latest version. Your draft is intact; try again.',
verifyFailed: 'The save could not be verified. Your draft is intact; try again.',
busy: 'The project is busy. Wait for indexing to finish, then try again.',
conflict: 'The server document changed. Review the latest version before saving your draft.',
mismatch: 'The server returned different content after saving. Your draft is intact; review the latest version.',
review: 'Review latest version',
hideReview: 'Hide latest version',
latest: 'Latest server version',
latestEmpty: 'The server returned no document content.',
reload: 'Discard draft and load latest',
rebase: 'Use latest as edit base',
rebased: 'Your draft remains in the editor. Incorporate the reviewed changes before saving.',
storageUnavailable: 'The draft is kept for this page session, but browser storage is unavailable. Keep this page open until you save.',
size: (bytes: number) => `${bytes.toLocaleString('en-US')} / 8,000 bytes`,
sizeExceeded: 'Keep the document within 8,000 UTF-8 bytes.',
bodyExceeded: 'The encoded save request is too large. Shorten the document before saving.',
};
+77
View File
@@ -0,0 +1,77 @@
.adr-workspace {
--adr-border: #a9cbb420;
--adr-muted: #93aa9b;
--chat-border: var(--adr-border);
--chat-muted: var(--adr-muted);
--chat-accent: #abd4ba;
display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden;
color: #dce5df; background: #0d1410ed; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.adr-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 22px; border-bottom: 1px solid var(--adr-border); }
.adr-project { display: flex; align-items: baseline; gap: 10px; min-width: 0; flex-wrap: wrap; }
.adr-project strong { font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
.adr-project :is(span, time) { font-size: 11px; color: var(--adr-muted); }
.adr-actions, .adr-conflict-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.adr-workspace button { border: 1px solid #a4c3ae30; border-radius: 6px; padding: 7px 12px; color: #c7d9cd; background: #a1c3ae08; font: inherit; font-size: 12px; cursor: pointer; }
.adr-workspace button:hover:not(:disabled) { background: #a1c3ae18; border-color: #a4c3ae65; }
.adr-workspace button:disabled { cursor: default; opacity: .45; }
.adr-workspace .adr-primary { background: #a1c3ae1c; border-color: #a4c3ae48; color: #dcece2; }
.adr-workspace :is(button, textarea):focus-visible { outline: 2px solid #a8cdb7; outline-offset: 2px; }
.adr-body { display: grid; grid-template-columns: minmax(0, 1fr); flex: 1; min-height: 0; overflow: hidden; }
.adr-body[data-outline="true"] { grid-template-columns: minmax(0, 1fr) 190px; }
.adr-body[data-editing="true"][data-preview="false"] { grid-template-columns: minmax(0, 1fr); }
.adr-body[data-editing="true"][data-preview="true"] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.adr-body[data-editing="true"][data-preview="true"][data-outline="true"] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 170px; }
.adr-document-area { min-width: 0; overflow: auto; overscroll-behavior: contain; padding: clamp(20px, 4vw, 48px); scroll-padding-top: 24px; }
.adr-document { max-width: 80ch; margin: 0 auto; font-size: 14px; line-height: 1.75; }
.adr-workspace .cbm-chat-markdown { min-width: 0; overflow-wrap: anywhere; }
.adr-workspace .cbm-chat-markdown :is(h1, h2, h3, h4, h5, h6) { font-family: inherit; font-weight: 550; line-height: 1.3; letter-spacing: -.02em; margin: 1.7em 0 .65em; }
.adr-workspace .cbm-chat-markdown > :first-child { margin-top: 0; }
.adr-workspace .cbm-chat-markdown h1 { font-size: 27px; }
.adr-workspace .cbm-chat-markdown h2 { font-size: 21px; }
.adr-workspace .cbm-chat-markdown h3 { font-size: 16px; }
.adr-workspace .cbm-chat-markdown :is(h4, h5, h6) { font-size: 14px; }
.adr-workspace .cbm-chat-markdown p { margin: .75em 0; }
.adr-workspace .cbm-chat-markdown a { color: #b3d7c0; text-underline-offset: 3px; }
.adr-workspace .cbm-chat-markdown :is(ul, ol) { padding-left: 24px; }
.adr-workspace .cbm-chat-markdown li { margin: 5px 0; }
.adr-workspace .cbm-chat-markdown hr { margin: 2em 0; border: 0; border-top: 1px solid var(--adr-border); }
.adr-workspace .cbm-chat-markdown blockquote { margin-left: 0; border-left: 2px solid #789886; padding: 3px 16px; color: #a8bcae; }
.adr-workspace .cbm-chat-code-block { border: 1px solid var(--adr-border); border-radius: 7px; background: #07100b; overflow: hidden; }
.adr-workspace .cbm-chat-code-header { display: flex; align-items: center; justify-content: space-between; padding: 7px 11px; color: var(--adr-muted); background: #adccba09; }
.adr-workspace .cbm-chat-code-header button { padding: 3px 8px; }
.adr-workspace .cbm-chat-code-block pre { max-width: 100%; overflow: auto; margin: 0; padding: 14px; white-space: pre; }
.adr-workspace .cbm-chat-code-block pre > code { font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; color: #c8d8cd; }
.adr-workspace .cbm-chat-markdown :not(pre) > code { font-size: .9em; padding: 2px 4px; border-radius: 4px; color: #c4d9cb; background: #a6c8b410; }
.adr-workspace .cbm-chat-table-scroll { overflow-x: auto; }
.adr-workspace .cbm-chat-markdown table { width: 100%; border-collapse: collapse; font-size: 13px; }
.adr-workspace .cbm-chat-markdown :is(th, td) { border: 1px solid var(--adr-border); padding: 9px 11px; text-align: left; }
.adr-workspace .cbm-chat-markdown th { background: #a1c3ae0b; font-weight: 550; }
.adr-workspace .cbm-chat-markdown .task-list-item { list-style-type: none; }
.adr-outline { min-height: 0; overflow: auto; border-left: 1px solid var(--adr-border); padding: 24px 14px; }
.adr-outline p, .adr-section-label { color: var(--adr-muted); font-size: 11px; margin: 0 0 12px; }
.adr-outline button { display: block; width: 100%; border: 0; padding: 6px 8px; border-radius: 4px; background: transparent; text-align: left; line-height: 1.4; overflow-wrap: anywhere; color: var(--adr-muted); }
.adr-outline button[data-level="2"] { color: #c0d3c7; }
.adr-outline button:is([data-level="3"], [data-level="4"], [data-level="5"], [data-level="6"]) { padding-left: 18px; font-size: 11px; }
.adr-editor { width: 100%; height: 100%; min-height: 0; min-width: 0; resize: none; border: 0; border-radius: 0; border-right: 1px solid var(--adr-border); padding: 24px; background: #080f0bd9; color: #c9dacf; font: 13px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
.adr-edit-info { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 9px 22px; color: var(--adr-muted); font-size: 11px; border-bottom: 1px solid var(--adr-border); }
.adr-limit-exceeded { color: #d8baa4; }
.adr-notice { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 22px; margin: 0; border-bottom: 1px solid var(--adr-border); color: #c2cdbf; background: #d5c5a909; font-size: 12px; }
.adr-notice button { flex-shrink: 0; }
.adr-conflict { max-height: 48%; overflow: auto; flex-shrink: 0; }
.adr-latest { padding: 20px 24px; border-bottom: 1px solid var(--adr-border); font-size: 13px; line-height: 1.65; background: #070d09; }
.adr-conflict-actions { padding: 12px 22px; border-bottom: 1px solid var(--adr-border); }
.adr-empty { margin: auto; max-width: 60ch; padding: 40px 24px; color: var(--adr-muted); font-size: 14px; line-height: 1.65; }
.adr-empty h2 { font-size: 22px; font-weight: 500; color: #d0ddd5; letter-spacing: -.025em; }
@media (max-width: 1050px) {
.adr-body[data-editing="true"][data-preview="true"][data-outline] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.adr-body[data-editing="true"] .adr-outline { display: none; }
}
@media (max-width: 680px) {
.adr-toolbar { padding: 12px 14px; gap: 12px; }
.adr-body, .adr-body[data-outline] { grid-template-columns: minmax(0, 1fr); }
.adr-outline { display: none; }
.adr-body[data-editing="true"][data-preview="true"][data-outline] { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr); overflow: auto; }
.adr-document-area, .adr-editor { padding: 20px; }
.adr-notice { align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; }
}
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { expect, it, vi } from 'vitest';
import ActivityPanel from './ActivityPanel';
import { emptyAgentsState, withEvent } from './agent-store';
import type { AgentEvent } from './agent-event';
it('keeps unmapped evidence visible and only opens recorded graph locations', async () => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement('div'); document.body.append(host);
const root = createRoot(host);
const event: AgentEvent = { ts: 1000, agent: 'Ada', run: 'one', seq: 1, phase: 'end',
tool: 'Read', path: 'missing.ts', detail: 'inspect', source: 'bridge', replay: false };
const onOpenNode = vi.fn();
const props = { state: withEvent(emptyAgentsState(), event), on: false, port: 4142,
status: { state: 'off' as const, origin: '', requests: 0, drops: 0, hello: undefined, error: '' },
onToggle: vi.fn(), onOpenNode };
try {
await act(async () => root.render(<ActivityPanel {...props} graph={{ nodes: [], edges: [], total_nodes: 100 }} />));
expect(host.querySelector('.cbm-activity-filters input')).toBeNull();
expect(host.querySelectorAll('.cbm-activity-filters select')).toHaveLength(3);
await act(async () => host.querySelector<HTMLButtonElement>('.cbm-activity-row')!.click());
expect(host.querySelector('.cbm-activity-inspector')?.textContent).toContain('Unmapped');
expect(host.querySelector('.cbm-activity-inspector')?.textContent).toContain('loaded graph');
expect(host.querySelector('.cbm-activity-inspector')?.textContent).not.toContain('the index has no node');
expect(host.querySelector('.cbm-activity-inspector button')).toBeNull();
expect(host.textContent).toContain('Its result is not reported');
const node = { id: 1, x: 0, y: 0, z: 0, size: 1, color: 'green', label: 'Module', name: 'main', file_path: 'missing.ts' };
await act(async () => root.render(<ActivityPanel {...props} graph={{ nodes: [node], edges: [], total_nodes: 1 }} />));
await act(async () => host.querySelector<HTMLButtonElement>('.cbm-activity-inspector button')!.click());
expect(onOpenNode).toHaveBeenCalledWith(node);
} finally { await act(async () => root.unmount()); host.remove(); }
});
+96
View File
@@ -0,0 +1,96 @@
import { useMemo, useState } from 'react';
import type { JSX } from 'react';
import type { AgentEvent } from './agent-event';
import { eventKey, workKindOf } from './agent-event';
import type { AgentsState } from './agent-store';
import type { AgentSourceStatus } from './agent-source';
import type { GraphData, GraphNode } from '../galaxy/types';
import { buildPlacementIndex, placeEvent } from './agent-placement';
import { activityRows } from './activity-model';
import type { ActivityFilter } from './activity-model';
import { activityStrings as s } from './activity-strings';
import './activity.css';
import AgentSetup from './AgentSetup';
import type { AtlasApi } from '../app/atlas-api';
interface Props {
state: AgentsState; status: AgentSourceStatus; on: boolean; port: number;
project?: string; api?: Pick<AtlasApi, 'repoInfo'>;
graph?: GraphData; onToggle: () => void; onOpenNode: (node: GraphNode) => void;
}
const emptyFilter: ActivityFilter = { agent: '', run: '', kind: '' };
const rowKey = (row: AgentEvent): string => `${row.agent}:${eventKey(row)}`;
export default function ActivityPanel(props: Props): JSX.Element {
const [filter, setFilter] = useState<ActivityFilter>(emptyFilter);
const [selected, setSelected] = useState<string>();
const rows = useMemo(() => activityRows(props.state, filter), [props.state, filter]);
const all = useMemo(() => activityRows(props.state, emptyFilter), [props.state]);
const index = useMemo(() => buildPlacementIndex(props.graph?.nodes ?? []), [props.graph]);
const entry = all.find((row) => rowKey(row) === selected);
const placement = entry === undefined ? undefined : placeEvent(entry, workKindOf(entry.tool, entry.detail), index);
const target = placement?.nodeId === undefined ? undefined : props.graph?.nodes.find((node) => node.id === placement.nodeId);
const agents = [...new Set(all.map((row) => row.agent))].sort();
const runs = [...new Set(all.filter((row) => !filter.agent || row.agent === filter.agent).map((row) => row.run))].sort();
const set = (patch: Partial<ActivityFilter>): void => setFilter((value) => ({ ...value, ...patch }));
return <section className="cbm-activity" aria-label={s.title}>
<header className="cbm-page-head">
<div><p className="cbm-eyebrow">{s.live}</p><h2>{s.title}</h2><p>{s.subtitle}</p></div>
<div className="cbm-activity-connection"><span data-state={props.status.state}>{props.status.state}</span>
<button type="button" onClick={props.onToggle}>{props.on ? s.disconnect : s.connect}</button></div>
</header>
<AgentSetup api={props.api} project={props.project} port={props.port} />
<p className="cbm-activity-source-status">{props.on ? props.status.state === 'connected' ? 'History polling connected' : 'Waiting for daemon history' : 'History polling off'} · {all.length ? <>Latest recorded tool call: <time dateTime={new Date(Math.max(...all.map(row => row.ts))).toISOString()}>{new Date(Math.max(...all.map(row => row.ts))).toLocaleString()}</time>. This does not mean an agent is currently working.</> : 'No recorded tool call has been received for this project.'}</p>
<div className="cbm-activity-metrics">
{[[s.events, props.state.actors.filter((actor) => !actor.you).reduce((sum, actor) => sum + actor.count, 0)], [s.actors, props.state.actors.filter((actor) => !actor.you).length],
[s.gaps, props.state.missed], [s.dropped, props.status.drops]].map(([label, value]) =>
<div key={label}><strong>{value}</strong><span>{label}</span></div>)}
</div>
{!props.on && <p className="cbm-activity-notice">{all.length > 0 ? s.disconnected : s.noSource} <code>{`127.0.0.1:${props.port}`}</code></p>}
{props.status.error && <p role="status" className="cbm-activity-notice">{props.status.error}</p>}
{props.state.unreadable > 0 && <p role="status" className="cbm-activity-notice">{s.unreadable}: {props.state.unreadable}</p>}
<div className="cbm-activity-filters">
<select aria-label={s.agent} value={filter.agent} onChange={(event) => set({ agent: event.target.value, run: '' })}>
<option value="">{s.allAgents}</option>{agents.map((value) => <option key={value}>{value}</option>)}
</select>
<select aria-label={s.run} value={filter.run} onChange={(event) => set({ run: event.target.value })}>
<option value="">{s.allRuns}</option>{runs.map((value) => <option key={value}>{value}</option>)}
</select>
<select aria-label={s.kind} value={filter.kind} onChange={(event) => set({ kind: event.target.value as ActivityFilter['kind'] })}>
<option value="">{s.allKinds}</option>{s.kinds.map((value) => <option key={value}>{value}</option>)}
</select>
<button type="button" onClick={() => setFilter(emptyFilter)}>{s.clear}</button>
</div>
<div className="cbm-activity-body">
<div className="cbm-activity-list" role="list" aria-label={s.title}>
{rows.length === 0 && <div className="cbm-activity-empty">{all.length === 0 ? s.noEvents : s.noMatch}</div>}
{rows.map((row) => {
const kind = workKindOf(row.tool, row.detail);
const mapped = placeEvent(row, kind, index);
return <button type="button" key={rowKey(row)} className="cbm-activity-row"
aria-pressed={rowKey(row) === selected} onClick={() => setSelected(rowKey(row))}>
<span className="cbm-activity-dot" data-kind={kind} />
<span><strong>{row.agent}</strong><span className="cbm-activity-action">{row.tool}</span>
<code>{row.path || row.detail || s.unknown}</code>
<small>{row.run} · {row.phase} · {row.replay ? s.replay : s.live}{mapped.kind === 'none' ? ` · ${s.unmapped}` : ''}</small></span>
<time dateTime={Number.isFinite(new Date(row.ts).getTime()) ? new Date(row.ts).toISOString() : undefined}>{new Date(row.ts).toLocaleTimeString()}</time>
</button>;
})}
</div>
<aside className="cbm-activity-inspector"><h3>{s.evidence}</h3>
{entry === undefined ? <p>{s.select}</p> : <>
<h4>{entry.tool}</h4><p>{entry.agent} · {entry.run}</p>
<dl>{[[s.timestamp, new Date(entry.ts).toLocaleString()], [s.phase, entry.phase],
[s.source, entry.source || s.unknown], [s.mapping, placement?.kind === 'none' ? s.unmapped : placement?.kind ?? s.unknown]]
.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
{entry.path && <code>{entry.path}{entry.lines ? `:${entry.lines.join('-')}` : ''}</code>}
{placement?.why && <p>{placement.kind === 'none' && entry.path ? s.noGraphMatch : placement.why}</p>}
{target !== undefined && <button type="button" onClick={() => props.onOpenNode(target)}>{s.open}</button>}
<h4>{s.details}</h4><pre>{entry.detail || s.unknown}</pre>
<p className="cbm-activity-note">{s.outcome}</p>
</>}
</aside>
</div>
<footer><p>{s.retained}</p><p className="atlas-guidance-note">{s.limited}</p></footer>
</section>;
}
+34
View File
@@ -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<string, unknown>).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(<AgentSetup api={api} project="project-a" port={9749} />));
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(<AgentSetup api={api} project="a" port={9749} />)); await open();
expect(host.querySelector('pre')?.textContent).toContain('/repo-a');
await act(async () => root.render(<AgentSetup api={api} project="b" port={9749} />));
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)'");
});
+50
View File
@@ -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<AtlasApi, 'repoInfo'>; 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 <details className="cbm-agent-setup" onToggle={event => setOpen(event.currentTarget.open)}>
<summary>Connect your agent</summary>
<p>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.</p>
{!project || !api ? <p>Select an indexed project to generate its setup command.</p> : <>
<ol>
<li><button type="button" onClick={download}>Download hook</button> Save <code>cbm-atlas-trace.py</code> in Downloads, or adjust its path in the command below.</li>
<li>Review and run this command in your terminal. It copies the hook into <code>.claude/hooks/</code> and adds it to <code>.claude/settings.local.json</code>, preserving unrelated settings. Conflicting existing hooks are refused.
{current?.error ? <p role="alert">Setup unavailable: {current.error}</p> : command ? <>
<pre>{command}</pre><button type="button" onClick={() => { void navigator.clipboard.writeText(command).then(() => setCopied('Command copied')).catch(() => setCopied('Clipboard unavailable; select and copy the command.')); }}>Copy setup command</button> <span role="status">{copied}</span>
</> : <p>Reading the indexed repository root…</p>}
</li>
<li>Start a new Claude Code session in that repository and perform a tool call. Then choose <strong>Load recorded activity</strong> here. A new recorded event is the evidence that the hook reached this daemon.</li>
</ol>
<p>Destination: <code>http://127.0.0.1:{port}/api/agent-events</code> · Project: <code>{project}</code>. No bridge or extra listener. Pending events retry on the next tool call.</p>
</>}
<p>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.</p>
<p>To remove it, delete only the entry referencing <code>cbm-atlas-trace.py</code> from this project's <code>PostToolUse</code> hooks. Other coding clients need their own event adapter; their activity is not inferred.</p>
</details>;
}
+363
View File
@@ -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<string, unknown>).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>): 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<ActorView> & { 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<number>(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> = {}): 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> = {}): 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> = {}): 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<AgentsHudProps> = {}): Promise<AgentsHudProps> {
const use = props(over);
await act(async () => {
root.render(<AgentsHud {...use} />);
});
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');
});
});
+759
View File
@@ -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 (
<Hint
name={`agents-strip-${props.actor.id}`}
text={text.stripTitle(STRIP_SECONDS, props.actor.stripTotal)}
>
<span
className="atlas-agents-strip"
data-testid="atlas-agents-strip"
data-actor={props.actor.id}
data-bars={props.actor.strip.join(',')}
data-total={props.actor.stripTotal}
data-seconds={STRIP_SECONDS}
aria-hidden="true"
>
{props.actor.strip.map((count, index) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={index}
className="atlas-agents-bar"
data-count={count}
style={{
height: `${count === 0 ? 1 : Math.round((count / peak) * 100)}%`,
background: count === 0 ? undefined : props.actor.color,
}}
/>
))}
</span>
</Hint>
);
}
/** 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 (
<Hint name={`agents-intent-${props.actor.id}`} text={text.intentTitle}>
<span
className="atlas-agents-intent"
data-testid="atlas-agents-intent"
data-actor={props.actor.id}
data-self-reported="true"
>
<span className="atlas-agents-intent-prefix">{text.intentPrefix}</span>
{` ${props.actor.intent}`}
</span>
</Hint>
);
}
/** Der Ort, mit seinem Tooltip. In beiden Lagen derselbe Text. */
function Place(props: { actor: ActorView; className: string }): JSX.Element {
const { actor } = props;
return (
<Hint
name={`agents-place-${actor.id}`}
text={
actor.placement.kind === 'none'
? text.placeNoneTitle(actor.placement.why)
: actor.placement.uncertain
? text.placeUncertainTitle
: actor.placement.qualifiedName
}
>
<span className={props.className} data-testid="atlas-agents-place">
{placeText(actor)}
</span>
</Hint>
);
}
/**
* 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 = <Place actor={actor} className="atlas-agents-place" />;
return (
<li
className="atlas-agents-row"
data-testid="atlas-agents-row"
data-actor={actor.id}
data-kind={actor.kind}
data-letter={actor.letter}
data-you={actor.you}
data-idle={actor.idle}
data-drawn={actor.drawn}
data-lines={column ? 2 : 1}
data-placement={actor.placement.kind}
data-uncertain={actor.placement.uncertain}
data-node={actor.placement.nodeId ?? ''}
>
<span className="atlas-agents-row-head">
<span
className="atlas-agents-dot"
data-testid="atlas-agents-dot"
data-color={actor.color}
style={{ background: actor.color }}
aria-hidden="true"
/>
<span className="atlas-agents-name" data-testid="atlas-agents-name">
{actor.name}
</span>
<Hint
name={`agents-kind-${actor.id}`}
text={text.kindTitle(actor.kind, actor.kindLetter)}
>
<span
className="atlas-agents-kind"
data-testid="atlas-agents-kind"
data-kind={actor.kind}
data-letter={actor.kindLetter}
>
{actor.kindLetter}
</span>
</Hint>
{!expanded && !column && place}
{!column && <Strip actor={actor} />}
<Hint
name={`agents-since-${actor.id}`}
text={actor.idle ? text.idleTitle(actor.sinceMs / 1000) : text.orderTitle}
>
<span className="atlas-agents-since" data-testid="atlas-agents-since">
{spell(actor.hereMs)}
</span>
</Hint>
</span>
{/*
* 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 && (
<span className="atlas-agents-row-second">
{place}
<Strip actor={actor} />
</span>
)}
{expanded && !column && (
<Place actor={actor} className="atlas-agents-place atlas-agents-place-full" />
)}
<Intent actor={actor} />
{expanded && actor.kind === 'test' && (
<span
className="atlas-agents-tested"
data-testid="atlas-agents-tested"
data-node={actor.testedNode?.id ?? ''}
>
{actor.testedNode === undefined
? text.testedUnknown
: text.testedTitle(actor.testedNode.name)}
</span>
)}
{/*
* 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) && (
<span
className="atlas-agents-path"
data-testid="atlas-agents-path"
data-count={actor.paths.length}
>
{actor.paths.length === 0 ? text.pathEmpty : actor.paths.join(' < ')}
</span>
)}
{expanded && (
<span className="atlas-agents-card" data-testid="atlas-agents-card">
<span className="atlas-agents-card-line">
{`${actor.count} events, ${
actor.missed === 0 ? text.orderIntact : text.orderMissed(actor.missed)
}`}
</span>
<span className="atlas-agents-card-line">
{`${actor.last.tool} ${actor.last.detail}`}
</span>
</span>
)}
</li>
);
}
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<HTMLDivElement | null>(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 (
<aside
className="atlas-agents"
data-testid="atlas-agents"
data-size={size}
data-source={status.state}
data-layer={props.layerOn}
data-filter={props.filter}
data-column={column}
aria-label={text.panelLabel}
>
<header className="atlas-agents-head" data-testid="atlas-agents-head">
<span className="atlas-agents-head-row">
<span className="atlas-agents-title">{text.title}</span>
{collapsed ? (
<span
className="atlas-agents-line"
data-testid="atlas-agents-line"
data-count={view.all.length}
>
{text.collapsedLine(view.all.length)}
</span>
) : (
<span className="atlas-agents-source-head" data-testid="atlas-agents-reading">
{sourceHead()}
</span>
)}
<span className="atlas-agents-head-buttons">
{!collapsed && (
<Hint name="agents-expand" text={text.expandTitle(expanded)}>
<button
type="button"
className="atlas-agents-button"
data-testid="atlas-agents-expand"
aria-pressed={expanded}
onClick={() => props.onSize(expanded ? 'compact' : 'expanded')}
>
{text.expand(expanded)}
</button>
</Hint>
)}
<Hint name="agents-fold" text={text.foldTitle(!collapsed)}>
<button
type="button"
className="atlas-agents-button"
data-testid="atlas-agents-fold"
aria-expanded={!collapsed}
data-fold={collapsed ? 'open' : 'collapse'}
onClick={() => props.onSize(collapsed ? 'compact' : 'collapsed')}
>
{text.fold(!collapsed)}
</button>
</Hint>
</span>
</span>
{!collapsed && (
<span className="atlas-agents-head-row">
<span
className="atlas-agents-count"
data-testid="atlas-agents-count"
data-count={view.all.length}
>
{text.agentCount(view.all.length)}
</span>
<span
className="atlas-agents-rate"
data-testid="atlas-agents-rate"
data-per-minute={view.perMinute}
data-events={view.events}
>
{text.perMinute(view.perMinute)}
</span>
<Hint name="agents-order" text={text.orderTitle}>
<span
className="atlas-agents-order"
data-testid="atlas-agents-order"
data-missed={view.missed}
>
{view.missed === 0 ? text.orderIntact : text.orderMissed(view.missed)}
</span>
</Hint>
</span>
)}
</header>
{!collapsed && (
<div
className="atlas-agents-body"
data-testid="atlas-agents-body"
data-more-above={edge.above}
data-more-below={edge.below}
ref={body}
onScroll={measure}
>
{showSourceDetail && (
<div
className="atlas-agents-source"
data-testid="atlas-agents-source"
data-state={status.state}
data-mode={status.hello?.mode ?? ''}
>
{status.state === 'off' && (
<span className="atlas-agents-source-detail">{text.sourceOffDetail}</span>
)}
{status.state === 'no-source' && (
<>
<span className="atlas-agents-source-detail">
{text.sourceNoneDetail}
</span>
<code
className="atlas-agents-command"
data-testid="atlas-agents-command"
data-command={bridgeCommand(props.port)}
>
{bridgeCommand(props.port)}
</code>
</>
)}
{status.hello?.mode === 'replay' && (
<span
className="atlas-agents-source-detail"
data-testid="atlas-agents-replay"
>
{text.replayNote}
</span>
)}
{view.unreadable > 0 && (
<span
className="atlas-agents-source-detail"
data-testid="atlas-agents-unreadable"
>
{text.unreadableLines(view.unreadable)}
</span>
)}
</div>
)}
{!props.layerOn && (
<p className="atlas-agents-note" data-testid="atlas-agents-layer-off">
{text.layerOff}
</p>
)}
<div
className="atlas-agents-filter"
data-testid="atlas-agents-filter"
role="group"
aria-label={text.filterLabel}
>
<span className="atlas-agents-filter-label">{text.filterLabel}</span>
{FILTERS.map((option) => (
<Hint
key={option}
name={`agents-filter-${option}`}
text={text.filterTitle(option)}
>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-filter-option"
data-option={option}
data-active={props.filter === option}
aria-pressed={props.filter === option}
onClick={() => props.onFilter(option)}
>
{filterLabel(option)}
</button>
</Hint>
))}
</div>
<ul
className="atlas-agents-rows"
data-testid="atlas-agents-rows"
data-count={view.actors.length}
data-column={column}
>
{view.actors.map((actor) => (
<Row
key={actor.id}
actor={actor}
trails={props.switches.trails}
expanded={expanded}
column={column}
/>
))}
</ul>
{/*
* Der Deckel sagt sich selbst.
*
* Er greift an den Koerpern und nicht an den Zeilen: jeder
* Akteur steht oben, mit allen seinen Zahlen. Was fehlt,
* ist ein Punkt auf dem Graphen, und ein Bild, dem man
* nicht ansieht, dass es unvollstaendig ist, waere die
* stille Behauptung, es sei vollstaendig.
*/}
{view.capped > 0 && (
<Hint name="agents-cap" text={text.capTitle}>
<p
className="atlas-agents-cap"
data-testid="atlas-agents-cap"
data-cap={view.cap}
data-capped={view.capped}
data-drawn={view.actors.filter((actor) => actor.drawn).length}
>
{text.capLine(
view.actors.filter((actor) => actor.drawn).length,
view.actors.length,
)}
</p>
</Hint>
)}
{/*
* Der Ereignis-Ticker im Klartext (AC5). Nur in der Spalte:
* im Panel waeren sechs weitere Zeilen der Unterschied
* zwischen einem Instrument und einem Protokoll.
*/}
{column && view.ticker.length > 0 && (
<div
className="atlas-agents-ticker"
data-testid="atlas-agents-ticker"
data-count={view.ticker.length}
>
<Hint name="agents-ticker" text={text.tickerTitle}>
<span className="atlas-agents-ticker-head">{text.tickerLabel}</span>
</Hint>
{view.ticker.map((entry) => (
<span
key={`${entry.ts}-${entry.actor}-${entry.tool}-${entry.path}`}
className="atlas-agents-ticker-row"
data-testid="atlas-agents-ticker-row"
data-actor={entry.actor}
data-kind={entry.kind}
data-place={entry.place}
data-lines={entry.lines.join('-')}
style={{ ['--atlas-agent-color' as string]: entry.color }}
>
{text.tickerLine(
entry.name,
WORK_KIND_WORD[entry.kind],
entry.place,
entry.lines,
)}
</span>
))}
</div>
)}
{view.unmapped.length > 0 && (
<div
className="atlas-agents-unmapped"
data-testid="atlas-agents-unmapped"
data-count={view.unmapped.length}
>
<Hint name="agents-unmapped" text={text.unmappedIntro}>
<span className="atlas-agents-unmapped-head">
{text.unmappedTitle(view.unmapped.length)}
</span>
</Hint>
{expanded && (
<span className="atlas-agents-unmapped-intro">
{text.unmappedIntro}
</span>
)}
{expanded && view.unmapped.map((event) => (
<span
className="atlas-agents-unmapped-row"
data-testid="atlas-agents-unmapped-row"
data-tool={event.tool}
data-path={event.path}
data-why={event.why}
key={`${event.ts}-${event.tool}-${event.path}`}
>
{text.unmappedRow(event.tool, event.path, event.why)}
</span>
))}
</div>
)}
<div
className="atlas-agents-switches"
data-testid="atlas-agents-switches"
role="group"
aria-label={text.title}
>
<Hint name="agents-follow" text={text.followTitle(props.switches.follow)}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-switch"
data-switch="follow"
data-active={props.switches.follow}
aria-pressed={props.switches.follow}
onClick={() => props.onSwitch('follow')}
>
{text.follow}
</button>
</Hint>
<Hint name="agents-trails" text={text.trailsTitle(props.switches.trails)}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-switch"
data-switch="trails"
data-active={props.switches.trails}
aria-pressed={props.switches.trails}
onClick={() => props.onSwitch('trails')}
>
{text.trails}
</button>
</Hint>
<Hint
name="agents-fullscreen"
text={text.fullscreenTitle(props.switches.fullscreen)}
>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-switch"
data-switch="fullscreen"
data-active={props.switches.fullscreen}
aria-pressed={props.switches.fullscreen}
onClick={() => props.onSwitch('fullscreen')}
>
{text.fullscreen}
</button>
</Hint>
{props.switches.trails && TRAIL_WINDOWS.map((ms) => (
<Hint key={ms} name={`agents-window-${ms}`} text={text.windowTitle}>
<button
type="button"
className="atlas-agents-option atlas-agents-window-option"
data-testid="atlas-agents-window-option"
data-option={ms}
data-active={props.trailWindowMs === ms}
aria-pressed={props.trailWindowMs === ms}
onClick={() => props.onTrailWindow(ms)}
>
{text.windowOption(ms)}
</button>
</Hint>
))}
</div>
{expanded && props.switches.fullscreen && (
<p className="atlas-agents-note" data-testid="atlas-agents-fullscreen-scope">
{text.fullscreenScope}
</p>
)}
</div>
)}
{(edge.above || edge.below) && !collapsed && (
<span
className="atlas-agents-more"
data-testid="atlas-agents-more-mark"
data-scroll-hint={[edge.above ? 'top' : '', edge.below ? 'bottom' : '']
.filter((part) => part.length > 0)
.join(' ')}
>
{edge.below ? '▾ more' : '▴ more'}
</span>
)}
</aside>
);
}
+216
View File
@@ -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<HTMLDivElement | null>(null);
const scrub = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
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 (
<div
className="atlas-agents-timeline"
data-testid="atlas-agents-timeline"
data-mode={timeline.mode}
data-tracks={timeline.tracks.length}
data-ticks={timeline.ticks}
data-window={timeline.windowMs}
data-from={timeline.from}
data-to={timeline.to}
>
<div className="atlas-agents-timeline-head">
<span className="atlas-agents-timeline-label">{text.timelineLabel}</span>
<Hint
name="agents-timeline-mode"
text={
timeline.mode === 'replay'
? text.timelineReplayTitle
: timeline.mode === 'paused'
? text.timelinePauseTitle
: text.timelineLiveTitle
}
>
<span
className="atlas-agents-timeline-mode"
data-testid="atlas-agents-timeline-mode"
data-mode={timeline.mode}
>
{modeLabel(timeline)}
</span>
</Hint>
<Hint name="agents-timeline-pause" text={text.timelinePauseTitle}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-timeline-pause"
data-active={timeline.mode === 'paused'}
aria-pressed={timeline.mode === 'paused'}
onClick={props.onPause}
>
{timeline.mode === 'paused' ? text.timelineResume : text.timelinePause}
</button>
</Hint>
{timeline.mode === 'replay' && (
<>
<span
className="atlas-agents-timeline-replay"
data-testid="atlas-agents-timeline-replay"
>
{text.timelineReplayNote(text.ago((props.now - timeline.to) / 1000))}
</span>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-timeline-live"
onClick={props.onLive}
>
{text.timelineBackToLive}
</button>
</>
)}
<span className="atlas-agents-timeline-windows">
{TIMELINE_WINDOWS.map((ms) => (
<Hint key={ms} name={`agents-timeline-window-${ms}`} text={text.windowTitle}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-timeline-window"
data-option={ms}
data-active={props.windowMs === ms}
aria-pressed={props.windowMs === ms}
onClick={() => props.onWindow(ms)}
>
{text.windowOption(ms)}
</button>
</Hint>
))}
</span>
</div>
<div className="atlas-agents-timeline-body">
<div className="atlas-agents-timeline-names">
{timeline.tracks.map((track) => (
<span
key={track.id}
className="atlas-agents-timeline-name"
data-testid="atlas-agents-timeline-name"
data-actor={track.id}
data-idle={track.idle}
style={{ color: track.color }}
>
{track.name}
</span>
))}
</div>
{/*
* 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.
*/}
<div
className="atlas-agents-timeline-lanes"
data-testid="atlas-agents-timeline-lanes"
ref={lanes}
role="slider"
tabIndex={0}
aria-label={text.timelineLabel}
aria-valuemin={timeline.from}
aria-valuemax={timeline.to}
aria-valuenow={timeline.to}
onClick={scrub}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
const step = timeline.windowMs / 20;
props.onScrub(timeline.to + (event.key === 'ArrowLeft' ? -step : step));
}
}}
>
{timeline.tracks.map((track) => (
<Hint
key={track.id}
name={`agents-timeline-track-${track.id}`}
text={text.timelineTrackTitle(track.name, track.count)}
>
<div
className="atlas-agents-timeline-track"
data-testid="atlas-agents-timeline-track"
data-actor={track.id}
data-count={track.count}
data-idle={track.idle}
>
{track.ticks.map((tick) => (
<span
key={`${tick.ts}-${tick.at}`}
className="atlas-agents-timeline-tick"
data-testid="atlas-agents-timeline-tick"
data-kind={tick.kind}
style={{
left: `${(tick.at * 100).toFixed(3)}%`,
background: track.color,
}}
aria-hidden="true"
/>
))}
</div>
</Hint>
))}
</div>
</div>
</div>
);
}
@@ -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>): 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);
});
+12
View File
@@ -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));
}
+19
View File
@@ -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,
};
+48
View File
@@ -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; }
+105
View File
@@ -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);
});
});
+141
View File
@@ -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<string, string> {
const out = new Map<string, string>();
const taken = new Set<string>();
// 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;
}
+136
View File
@@ -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<string, unknown> = { ...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');
});
});
+234
View File
@@ -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<Record<WorkKind, string>> = {
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<string, unknown> {
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, number>): string {
return [...seen.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([run, seq]) => `${run}:${seq}`)
.join(',');
}
+217
View File
@@ -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);
});
});
+402
View File
@@ -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 };
}
+182
View File
@@ -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<GraphNode> & { 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>): 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');
});
});
+292
View File
@@ -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<string, readonly GraphNode[]>;
/** 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<string, GraphNode[]>();
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,
};
}
@@ -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<string, string> = {}): PreferenceStore & { map: Map<string, string> } {
const map = new Map<string, string>(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);
});
});
+155
View File
@@ -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<T>(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<string, unknown>) : {};
return {
size: asOneOf<HudSize>(
record['size'], ['collapsed', 'compact', 'expanded'], DEFAULT_AGENTS_PREFERENCE.size,
),
filter: asOneOf<ActorFilter>(
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;
}
+98
View File
@@ -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<string, unknown>).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(<Consumer on={false} project="repo / test" fetch={fetch} />));
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(fetch).not.toHaveBeenCalled();
await act(async () => root.render(<Consumer on project="repo / test" fetch={fetch} />));
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(<Consumer on={false} project="repo / test" fetch={fetch} />));
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(<Consumer on project="demo" fetch={fetch} />));
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(<Consumer on project="one" fetch={fetch} />));
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(<Consumer on project="two" fetch={fetch} />));
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(<Observe project="one" />));
expect(observed.at(-1)).toMatchObject({ project: 'one', events: 1, hello: true, status: 'connected' });
await act(async () => root.render(<Observe project="two" />));
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');
});
+272
View File
@@ -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<AgentsState>(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<string | undefined>(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<typeof setTimeout> | undefined;
const announce = (patch: Partial<AgentSourceStatus>): 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<void> => {
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]);
}
+174
View File
@@ -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>): 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']);
});
});
+392
View File
@@ -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<string, number>;
/** 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<number>(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<string, number>();
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;
}
+270
View File
@@ -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<Record<WorkKind, string>> = {
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<Record<WorkKind, string>> = {
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';
}
+117
View File
@@ -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);
});
});
+164
View File
@@ -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);
}
+124
View File
@@ -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<GraphNode> & { 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>): 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('');
});
});
+424
View File
@@ -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<number, GraphNode> {
const out = new Map<number, GraphNode>();
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<number, GraphNode>,
): GraphNode[] {
const out: GraphNode[] = [];
const seen = new Set<number>();
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<number, GraphNode>,
): 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<string>();
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),
};
}
-47
View File
@@ -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<T = unknown>(
name: string,
args: Record<string, unknown> = {},
): Promise<T> {
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;
}
+412
View File
@@ -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<string, unknown>).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> = {}): 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: <div data-testid="fake-reader" />,
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<void> {
await act(async () => {
root.render(<AtlasChrome {...next} />);
});
}
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: <button>Project</button> }));
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<HTMLButtonElement>('.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: <button>Project</button> }));
const button = container.querySelector<HTMLButtonElement>('.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: <input aria-label="Chat draft" defaultValue="conversation draft" /> });
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<HTMLInputElement>('[aria-label="Chat draft"]')!;
draft.value = 'retained draft';
const settingsButton = group?.querySelector<HTMLButtonElement>('.atlas-browser-ai-action')!;
const openButton = group?.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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: <button type="button">Switch project: atlas-sample</button> },
}));
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: <div data-testid="fake-overlay" /> }));
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: <div data-testid="fake-overlay" />,
twin: <aside data-testid="fake-twin" />, galaxy: <section data-testid="fake-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 = <input data-testid="preserved-reader" defaultValue="selected source" />;
const next = props({ children: reader, splitExplain: <div data-testid="explanation-splitter" /> });
await render(next);
const mountedReader = testId('preserved-reader');
expect(testId('explanation-splitter')).toBeNull();
await render({ ...next, explain: <section data-testid="opened-tool">Flow</section> });
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');
});
});
+821
View File
@@ -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
* `<span class="atlas-menu-key">` 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<Record<string, MenuWiring>>;
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<HTMLInputElement>) => 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<HTMLInputElement | null>;
/** 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 (
<span className="atlas-chip" data-state={chip.state ?? 'plain'} data-chip={chip.label}>
{chip.label} <b>{chip.value}</b>
</span>
);
}
/**
* 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<HTMLDivElement | null>(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<HTMLElement>('.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 (
<div
className="atlas-tabs-bar"
data-testid="atlas-tabs-bar"
data-overflowing={overflow.any}
data-dragging={dragging}
>
<div
className="atlas-tabs"
data-testid="atlas-tabs"
role="tablist"
ref={scroller}
onScroll={measure}
/*
* Ein Zug ist kein Klick. Ohne das waehlte jeder Zug den Tab
* aus, ueber dem die Maus zufaellig losgelassen wurde.
*/
onClickCapture={(event) => {
if (dragged.current) {
event.preventDefault();
event.stopPropagation();
dragged.current = false;
}
}}
onPointerDown={(event) => {
const node = scroller.current;
if (node === null || event.button !== 0) {
return;
}
dragged.current = false;
drag.current = { startX: event.clientX, startScroll: node.scrollLeft, moved: 0 };
}}
onPointerMove={(event) => {
const node = scroller.current;
const state = drag.current;
if (node === null || state === null) {
return;
}
const delta = event.clientX - state.startX;
state.moved = Math.max(state.moved, Math.abs(delta));
if (state.moved < 4) {
return;
}
dragged.current = true;
setDragging(true);
node.scrollLeft = state.startScroll - delta;
measure();
}}
onPointerUp={() => {
drag.current = null;
setDragging(false);
}}
onPointerLeave={() => {
drag.current = null;
setDragging(false);
}}
>
{props.tabs.length === 0 ? (
<span className="atlas-tabs-empty">{messages.app.noFileOpen}</span>
) : (
props.tabs.map((tab) => (
<span key={tab.path} className="atlas-tab" data-active={tab.active} data-path={tab.path}>
<Hint name="tab" text={tab.path}>
<button
type="button"
className="atlas-tab-label"
role="tab"
aria-selected={tab.active}
data-testid="atlas-tab"
onClick={() => props.onSelectTab(tab.path)}
>
{tab.name}
</button>
</Hint>
{tab.active && <span className="atlas-tab-dot">●</span>}
<button
type="button"
className="atlas-tab-close"
aria-label={messages.app.closeTab(tab.name)}
onClick={() => props.onCloseTab(tab.path)}
>
x
</button>
</span>
))
)}
</div>
{(['left', 'right'] as const).map((side) => (
<span
key={side}
className="atlas-tabs-overflow"
data-testid="atlas-tabs-overflow"
data-side={side}
data-on={side === 'left' ? overflow.left : overflow.right}
aria-hidden="true"
/>
))}
</div>
);
}
export const OPEN_COMMAND_SEARCH_EVENT = 'cbm:open-command-search';
export const CLOSE_COMMAND_SEARCH_EVENT = 'cbm:close-command-search';
export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
/*
* Ob der Fokus IRGENDWO in der Kommandozeile steht, das Feld oder eines
* ihrer Beispiele.
*
* Nicht nur am Feld, und das ist die ganze Bedingung dafuer, dass die
* Beispiele mit der Tastatur erreichbar sind: waeren sie an den Fokus des
* FELDES gebunden, wuerde die Tabulatortaste sie in dem Moment schliessen,
* in dem sie den ersten von ihnen erreicht. Ein Angebot, das verschwindet,
* sobald man danach greift, ist keines.
*/
/*
* Ob die Kommandozeile den Fokus hat.
*
* Der Zustand gehoert hierher und nicht nach oben: er entsteht am Feld, er
* faerbt das Feld, und niemand sonst braucht ihn. Ein `:focus-within` in CSS
* taete dasselbe fuer das Auge, aber nicht fuer den Beweislauf, der lesen
* koennen soll, was die Oberflaeche ueber sich behauptet.
*/
const [chatWidth, setChatWidth] = useState(420);
const [graphShare, setGraphShare] = useState(1 / 3);
const [chatColumnHeight, setChatColumnHeight] = useState(600);
const chatColumn = useRef<HTMLDivElement>(null);
const graphSpace = Math.max(1, chatColumnHeight - 8);
const graphHeight = graphSpace * graphShare;
const inlineGalaxyHost = useRef<HTMLDivElement>(null);
const chatGalaxyHost = useRef<HTMLDivElement>(null);
const inlineInspectorHost = useRef<HTMLDivElement>(null);
const chatInspectorHost = useRef<HTMLDivElement>(null);
const hasInspector = props.selectionInspector !== undefined;
const isExplore = (props.workspace ?? 'explore') === 'explore';
const inspectorAboveChat = props.chatOpen === true && isExplore && hasInspector;
// Moving the portal host preserves disclosures and focusable context controls.
const [inspectorMount] = useState(() => {
if (typeof document === 'undefined') return null;
const element = document.createElement('div');
element.className = 'atlas-selection-mount';
return element;
});
useLayoutEffect(() => {
const host = inspectorAboveChat ? chatInspectorHost.current : inlineInspectorHost.current;
if (host !== null && inspectorMount !== null) host.appendChild(inspectorMount);
return () => { inspectorMount?.remove(); };
}, [hasInspector, inspectorAboveChat, inspectorMount]);
// A stable portal keeps the WebGL canvas and its camera alive between hosts.
const [galaxyMount] = useState(() => {
if (typeof document === 'undefined') return null;
const element = document.createElement('div');
element.className = 'atlas-galaxy-mount';
return element;
});
const explorerDock = isExplore && !hasInspector && props.chatDock !== undefined && props.galaxy !== undefined;
const showChatColumn = props.chatOpen === true || explorerDock;
const graphBelowChat = (props.chatOpen === true || explorerDock) && isExplore && props.galaxy !== undefined;
useLayoutEffect(() => {
const element = chatColumn.current;
if (!element || !showChatColumn) return;
const measure = () => { if (element.clientHeight > 0) setChatColumnHeight(element.clientHeight); };
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [showChatColumn]);
useLayoutEffect(() => {
const host = graphBelowChat ? chatGalaxyHost.current : inlineGalaxyHost.current;
if (host !== null && galaxyMount !== null) host.appendChild(galaxyMount);
return () => { galaxyMount?.remove(); };
}, [galaxyMount, graphBelowChat]);
useEffect(() => {
const focusGalaxySearch = () => {
props.onWorkspaceChange?.('galaxy');
requestAnimationFrame(() => window.dispatchEvent(new Event('cbm:focus-galaxy-search')));
};
const onKey = (event: globalThis.KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
focusGalaxySearch();
}
};
window.addEventListener(OPEN_COMMAND_SEARCH_EVENT, focusGalaxySearch);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener(OPEN_COMMAND_SEARCH_EVENT, focusGalaxySearch);
window.removeEventListener('keydown', onKey);
};
}, [props.onWorkspaceChange]);
/*
* Die Masse mit ihrer Vorgabe, an einer Stelle.
*
* Optional, weil das Chrome in jsdom auch ohne Layout-Zustand gezeichnet
* werden koennen soll (src/app/AtlasChrome.test.tsx zeichnet es ohne). Ohne
* Angabe gilt die Vorgabe aus dem Modell und nicht eine zweite Zahl hier:
* eine Vorgabe, die es zweimal gibt, ist keine.
*/
const zones = props.zones ?? LAYOUT_DEFAULT;
const hasSide =
hasInspector || props.twin !== undefined || (props.galaxy !== undefined && !graphBelowChat) || props.llm !== undefined;
return (
<div className="atlas-shell" data-workspace={props.workspace ?? 'explore'} data-guidance={props.guidance ?? 'brief'} data-chat-open={showChatColumn} data-has-status={props.workspaceStatus !== undefined}>
<header className="atlas-header" data-testid="atlas-header">
<h1 className="atlas-brand">{messages.app.brand}{' '}<span className="atlas-brand-beta">{messages.app.releaseLabel}</span></h1>
<span className="atlas-version" data-testid="atlas-version">
{props.version.replace(/-dirty$/, '')}
</span>
{/*
* 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 && (
<nav className="atlas-workspace-tabs" role="tablist" aria-label={workspaceStrings.navigation}>
{availableWorkspaces(props.experimentalAgents).map((workspace, index) => (
<button type="button" role="tab" key={workspace.id} data-workspace-tab={workspace.id}
tabIndex={(props.workspace ?? 'explore') === workspace.id ? 0 : -1}
aria-selected={(props.workspace ?? 'explore') === workspace.id}
onKeyDown={(event) => {
const choices = availableWorkspaces(props.experimentalAgents);
const next = event.key === 'ArrowRight' ? (index + 1) % choices.length
: event.key === 'ArrowLeft' ? (index + choices.length - 1) % choices.length
: event.key === 'Home' ? 0 : event.key === 'End' ? choices.length - 1 : -1;
if (next < 0) return;
event.preventDefault();
props.onWorkspaceChange?.(choices[next].id);
(event.currentTarget.parentElement?.querySelectorAll('button')[next] as HTMLButtonElement | undefined)?.focus();
}}
onClick={() => props.onWorkspaceChange?.(workspace.id)}>
{workspace.label}
</button>
))}
</nav>
)}
{props.onOpenSystem !== undefined && <button type="button" className="atlas-daemon-action" data-state={props.daemonState ?? 'checking'} onClick={props.onOpenSystem}
aria-label={workspaceStrings.daemonNavigation(props.daemonState ?? 'checking')}><span aria-hidden="true" />{workspaceStrings.daemon}</button>}
<div className="atlas-chips">
{props.chips.filter(chip => props.projectSwitcher === undefined || chip.label !== messages.statusbar.chipProject).map((chip) => (
<ChipView key={chip.label} chip={chip} />
))}
</div>
{props.projectSwitcher}
{(props.onOpenBrowserAi !== undefined || props.onToggleBrowserAi !== undefined) && <div className="atlas-agent-controls" role="group" aria-label={workspaceStrings.agentControls}>
{props.onOpenBrowserAi !== undefined && <button type="button" className="atlas-browser-ai-action" data-state={props.agentState ?? 'off'}
aria-label={workspaceStrings.agentSettings(workspaceStrings.agentStatus[props.agentState ?? 'off'], props.agentModelName || undefined)}
title={workspaceStrings.agentSettingsTitle(workspaceStrings.agentStatus[props.agentState ?? 'off'], props.agentModelName || undefined)} onClick={props.onOpenBrowserAi}>
{/* Derselbe Strich und dasselbe Raster wie der Chat-Schalter daneben; den Zustand traegt die Lampe. */}
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M10 3v3" /><circle cx="10" cy="2.5" r="1" fill="currentColor" stroke="none" /><rect x="3.5" y="6" width="13" height="10" rx="2.5" /><path d="M1.5 10v3M18.5 10v3" /><circle cx="7.5" cy="10.5" r="1.2" fill="currentColor" stroke="none" /><circle cx="12.5" cy="10.5" r="1.2" fill="currentColor" stroke="none" /><path d="M8 13.5h4" /></svg>
<span aria-hidden="true" />
</button>}
{props.onToggleBrowserAi !== undefined && <button type="button" className="atlas-agent-chat-toggle" aria-expanded={props.chatOpen === true} aria-label={props.chatOpen ? workspaceStrings.hideChat : workspaceStrings.openChat} title={props.chatOpen ? workspaceStrings.hideChat : workspaceStrings.openChat} onClick={props.onToggleBrowserAi}>
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3" width="16" height="14" rx="2" /><path d="M12 3v14" /><path className="atlas-agent-chat-toggle-pane" d="M12 3h4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4z" stroke="none" /></svg>
</button>}
</div>}
</header>
{props.workspaceStatus}
<div className="atlas-workspace-content" style={{ '--atlas-chat-width': `${chatWidth}px` } as CSSProperties}>
<div className="atlas-workspace-stage">
<div className="atlas-exploration-workspace" data-testid="atlas-exploration-workspace" hidden={props.workspace !== undefined && props.workspace !== 'explore' && props.workspace !== 'galaxy'}>
<TabBar tabs={props.tabs} onSelectTab={props.onSelectTab} onCloseTab={props.onCloseTab} />
{/*
* `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.
*/}
<div
className="atlas-body"
data-testid="atlas-body"
data-twin={hasSide}
style={{
'--atlas-left-w': `${zones.leftWidth}px`,
'--atlas-right-w': `${hasInspector ? Math.max(360, zones.rightWidth) : zones.rightWidth}px`,
'--atlas-twin-h': `${zones.twinHeight}px`,
} as CSSProperties}
>
<AtlasTree {...props.tree} />
{props.splitLeft}
<main className="atlas-main" data-testid="atlas-main">
<div className="atlas-breadcrumb" data-testid="atlas-breadcrumb">
{props.breadcrumb.length === 0 ? (
<span className="atlas-breadcrumb-empty">{messages.app.noFileOpen}</span>
) : (
props.breadcrumb.map((segment, index) => (
<span key={`${segment}-${index}`}>
{index > 0 && ' › '}
<span
className={
index === props.breadcrumb.length - 1
? 'atlas-breadcrumb-leaf'
: 'atlas-breadcrumb-step'
}
>
{segment}
</span>
</span>
))
)}
{props.readerActions !== undefined && <div className="atlas-reader-actions">{props.readerActions}</div>}
</div>
{props.readerSummary}
{props.readerDetails && <section className="atlas-reader-details" aria-label={workspaceStrings.codeDetails}>{props.readerDetails}</section>}
{props.coverageNote !== undefined && (
<p
className="atlas-coverage-note"
data-testid="atlas-coverage-note"
data-coverage={props.coverageNote.state}
>
<b>{props.coverageNote.state}: </b>
{props.coverageNote.text}
</p>
)}
<div className="atlas-reader" data-testid="atlas-reader">
{props.children}
</div>
{props.truncationNote.length > 0 && (
<p className="atlas-truncation" data-testid="atlas-truncation">
<b>{messages.reader.incompleteLabel}</b>
{props.truncationNote}
</p>
)}
{props.explain != null && <>
{props.splitExplain}
{props.explain}
</>}
</main>
{hasSide && (
<>
{props.splitRight}
<div className="atlas-side" data-galaxy={props.galaxy !== undefined} data-selection-inspector={hasInspector}>
<div ref={inlineInspectorHost} className="atlas-selection-host" hidden={!isExplore || !hasInspector || inspectorAboveChat}>
{inspectorMount === null && props.selectionInspector}
</div>
{!hasInspector && props.llm}
{!hasInspector && props.twin}
{!hasInspector && props.splitTwin}
<div ref={inlineGalaxyHost} className="atlas-galaxy-host">{galaxyMount === null && props.galaxy}</div>
</div>
</>
)}
</div>
</div>
<main className="atlas-alternate-workspace" hidden={props.workspace === undefined || props.workspace === 'explore' || props.workspace === 'galaxy'}>{props.workspacePanel}</main>
</div>
{showChatColumn && <Splitter testId="atlas-split-chat" orientation="vertical" label={workspaceStrings.chatWidth} value={chatWidth}
min={300} max={600} invert onChange={setChatWidth} onReset={() => setChatWidth(420)} />}
<div ref={chatColumn} className="atlas-chat-column" hidden={!showChatColumn} data-graph={graphBelowChat} data-selection-inspector={inspectorAboveChat} data-chat-expanded={props.chatOpen === true}
style={{ '--atlas-chat-graph-height': `${graphHeight}px`, '--atlas-chat-fraction': `${1 - graphShare}fr`, '--atlas-graph-fraction': `${graphShare}fr` } as CSSProperties}>
<div ref={chatInspectorHost} className="atlas-selection-host" hidden={!inspectorAboveChat} />
{props.chatDock}
{graphBelowChat && props.chatOpen === true && <Splitter testId="atlas-split-chat-graph" orientation="horizontal"
label={workspaceStrings.chatGraphHeight} value={graphHeight} min={graphSpace / 3} max={graphSpace * 0.85} invert
onChange={value => setGraphShare(Math.min(0.85, Math.max(1 / 3, value / graphSpace)))} onReset={() => setGraphShare(1 / 3)} />}
<div ref={chatGalaxyHost} className="atlas-galaxy-host" hidden={!graphBelowChat} />
</div>
{galaxyMount !== null && createPortal(props.galaxy, galaxyMount)}
{inspectorMount !== null && createPortal(props.selectionInspector, inspectorMount)}
</div>
{/*
* 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).
*/}
<div className="atlas-statusbar" data-testid="atlas-statusbar">
{props.status.filter((chip) => chip.label === messages.statusbar.chipServer).map((chip) => (
<ChipView key={chip.label} chip={chip} />
))}
<details className="atlas-status-details" open={props.guidance === 'explained'}>
<summary>{workspaceStrings.diagnostics}</summary>
<div>{props.status.filter((chip) => chip.label !== messages.statusbar.chipServer).map((chip) => (
<ChipView key={chip.label} chip={chip} />
))}</div>
</details>
</div>
{props.globalOverlay}
</div>
);
}
+308
View File
@@ -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(<AtlasTreeIndexDetails {...details} />));
}
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).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> = {}): 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<void> {
await act(async () => {
root.render(<AtlasTree {...next} />);
});
}
const rows = (): HTMLButtonElement[] =>
[...container.querySelectorAll<HTMLButtonElement>('[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<HTMLUListElement>('.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' }));
});
});
+305
View File
@@ -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<HTMLUListElement>) => 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<CoverageState>(['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<HTMLUListElement | null>(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<HTMLElement>('[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 (
<aside className="atlas-tree" data-testid="atlas-tree">
<h2 className="atlas-tree-title">{messages.explorer.title}</h2>
<ul
className="atlas-tree-list"
ref={listRef}
role="tree"
aria-label={messages.explorer.title}
tabIndex={0}
onKeyDown={props.onKeyDown}
>
<li>
<span className="atlas-tree-row" data-kind="dir" data-root="true">
<span className="atlas-tree-twisty">{'▾'}</span>
{props.projectName}/
</span>
</li>
{props.rows.map((row, index) => {
const state = coverageOf(row);
const mark = markFor(row);
return (
<li key={row.path} role="treeitem" aria-selected={row.path === props.activePath}>
<Hint name="tree-row" text={titleFor(row)}>
<button
type="button"
tabIndex={-1}
className="atlas-tree-row"
style={{ paddingLeft: `${8 + row.depth * 12}px` }}
data-kind={row.kind}
data-path={row.path}
data-active={row.path === props.activePath}
data-cursor={index === props.cursor}
data-coverage={state}
// Nur fuer Ordner eine Aussage. Bei einer Datei
// waere "nicht aufgeklappt" keine Lage, sondern
// eine Kategorie, die es nicht gibt.
data-expanded={row.kind === 'dir' ? row.expanded : undefined}
data-testid="atlas-tree-row"
onClick={() => {
props.onCursorChange(index);
if (row.kind === 'dir') {
props.onToggle(row);
} else {
props.onOpen(row);
}
}}
>
<span className="atlas-tree-twisty">{twistyFor(row)}</span>
{/*
* Der Status-Punkt, und er steht IMMER da.
*
* Nutzerfeedback vom 2026-08-29: bis dahin trug
* nur die Problemzeile ein Zeichen, und ein
* sauber indizierter Baum sah aus wie ein Baum
* ohne Coverage-Antwort. Die Anforderung war
* "sichtbar je Datei", nicht "sichtbar nur bei
* Problemen": dass alles indiziert ist, ist ein
* Befund und muss man sehen koennen.
*/}
{row.kind === 'file' && (
<span
className="atlas-tree-dot"
data-testid="atlas-tree-dot"
data-coverage={state}
data-tone={dotToneOf(state)}
aria-hidden="true"
/>
)}
{row.kind === 'dir' ? `${row.name}/` : row.name}
{mark.length > 0 && (
<span
className="atlas-tree-mark"
data-testid="atlas-tree-mark"
data-coverage={state}
aria-hidden="true"
>
{mark}
</span>
)}
<span className="atlas-tree-symbols">{row.symbols}</span>
</button>
</Hint>
</li>
);
})}
</ul>
</aside>
);
}
/** Coverage metadata belongs in the Coverage workspace, separate from file navigation. */
export function AtlasTreeIndexDetails(props: Pick<AtlasTreeProps, 'rows' | 'note' | 'noteIsAbsence' | 'truncations'>): JSX.Element {
const states = shownStates(props.rows);
const markedFolders = props.rows.some((row) => row.kind === 'dir' && coverageOf(row) !== 'indexed');
return <section aria-label={workspaceStrings.indexInventory}>
{props.note.length > 0 && (
<p className="atlas-tree-note" data-state={props.noteIsAbsence === true ? 'absent' : 'present'}>
{props.note}
</p>
)}
{(props.truncations ?? []).map((line) => (
<p className="atlas-tree-note" data-state="absent" data-testid="atlas-tree-truncation" key={line}>
{line}
</p>
))}
<div className="atlas-tree-legend" data-testid="atlas-tree-legend">
{/*
* 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) => (
<span className="atlas-tree-legend-entry" data-testid="atlas-tree-legend-entry" data-coverage={state} key={state}>
<span
className="atlas-tree-dot"
data-testid="atlas-tree-legend-dot"
data-coverage={state}
data-tone={dotToneOf(state)}
aria-hidden="true"
/>
<span className="atlas-tree-mark" data-coverage={state} aria-hidden="true">
{COVERAGE_MARKS[state].length > 0 ? COVERAGE_MARKS[state] : ' '}
</span>
<b>{COVERAGE_LABELS[state]}</b> {COVERAGE_DESCRIPTIONS[state]}
</span>
))}
{markedFolders && (
<span className="atlas-tree-legend-entry" data-testid="atlas-tree-legend-entry" data-coverage="folder">
<span className="atlas-tree-mark" data-coverage="folder" aria-hidden="true">
{COVERAGE_FOLDER_MARK}
</span>
<b>{messages.explorer.folderLabel}</b> {messages.explorer.folderDescription}
</span>
)}
<span className="atlas-tree-legend-source" data-testid="atlas-tree-legend-source">
{COVERAGE_SOURCE_NOTE}
</span>
</div>
</section>;
}
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { expect, it, vi } from 'vitest';
import WelcomePanel from './WelcomePanel';
it('allows first use without starting or setting up browser AI', async () => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement('div'); document.body.append(host);
const root = createRoot(host);
const onContinue = vi.fn(), onLocalAi = vi.fn();
try {
await act(async () => root.render(<WelcomePanel workspace="architecture"
onWorkspace={vi.fn()} onContinue={onContinue} onLocalAi={onLocalAi} />));
expect(onLocalAi).not.toHaveBeenCalled();
expect(document.activeElement).toBe(host.querySelector('.cbm-primary'));
await act(async () => host.querySelector<HTMLButtonElement>('.cbm-primary')!.click());
expect(onContinue).toHaveBeenCalledOnce();
expect(onLocalAi).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); host.remove(); }
});
it.each([false, true])('shows the Agents first-use choice only when opted in: %s', async experimentalAgents => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement('div'); document.body.append(host);
const root = createRoot(host);
const onWorkspace = vi.fn();
try {
await act(async () => root.render(<WelcomePanel workspace="architecture" experimentalAgents={experimentalAgents}
onWorkspace={onWorkspace} onContinue={vi.fn()} onLocalAi={vi.fn()} />));
const agents = [...host.querySelectorAll<HTMLButtonElement>('.cbm-welcome-choices button')]
.find(button => button.querySelector('strong')?.textContent === 'Agents');
expect(agents !== undefined).toBe(experimentalAgents);
if (agents) {
await act(async () => agents.click());
expect(onWorkspace).toHaveBeenCalledWith('agents');
}
} finally { await act(async () => root.unmount()); host.remove(); }
});

Some files were not shown because too many files have changed in this diff Show More