summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 17:05:12 -0300
committerGabriel Schneider <[email protected]>2026-07-30 17:56:23 -0300
commit8ec4e135e318c7799b2576a8729493c063da4a97 (patch)
treeab37cdbb7f478eb996264cf941891f127f303c85
parentcb05c363045bf0e7af5858f6d7bc26f026fa9d70 (diff)
downloadnotevi-8ec4e135e318c7799b2576a8729493c063da4a97.tar.gz
notevi-8ec4e135e318c7799b2576a8729493c063da4a97.zip
-rw-r--r--constrain/README.txt17
-rw-r--r--vr-log.jsonl1
-rw-r--r--vrsite/app_test.go316
-rw-r--r--vrsite/assets/app.js49
-rw-r--r--vrsite/assets/bash.scm21
-rw-r--r--vrsite/assets/c.scm36
-rw-r--r--vrsite/assets/cpp.scm25
-rw-r--r--vrsite/assets/go.scm34
-rw-r--r--vrsite/assets/javascript-params.scm12
-rw-r--r--vrsite/assets/javascript.scm52
-rw-r--r--vrsite/assets/json.scm13
-rw-r--r--vrsite/assets/jsx.scm16
-rw-r--r--vrsite/assets/python.scm50
-rw-r--r--vrsite/assets/style.css.tmpl102
-rw-r--r--vrsite/assets/typescript.scm15
-rw-r--r--vrsite/bench_test.go168
-rw-r--r--vrsite/go.mod8
-rw-r--r--vrsite/go.sum20
-rw-r--r--vrsite/highlight_test.go177
-rw-r--r--vrsite/jj.go18
-rw-r--r--vrsite/main.go60
-rw-r--r--vrsite/model.go54
-rw-r--r--vrsite/page.go106
-rw-r--r--vrsite/render.go130
-rw-r--r--vrsite/serve.go253
-rw-r--r--vrsite/static.go50
-rw-r--r--vrsite/templates/frag-activity.tmpl6
-rw-r--r--vrsite/templates/frag-change.tmpl41
-rw-r--r--vrsite/templates/frag-note.tmpl18
-rw-r--r--vrsite/templates/layout.tmpl5
-rw-r--r--vrsite/templates/page-files.tmpl4
-rw-r--r--vrsite/templates/page-index.tmpl10
-rw-r--r--vrsite/templates/page-notes.tmpl11
-rw-r--r--vrsite/templates/page-session.tmpl70
-rw-r--r--vrsite/transcript.go602
-rw-r--r--vrsite/transcript_test.go333
36 files changed, 2733 insertions, 170 deletions
diff --git a/constrain/README.txt b/constrain/README.txt
index 01657a8..4eee9a1 100644
--- a/constrain/README.txt
+++ b/constrain/README.txt
@@ -15,16 +15,21 @@ codex-config.toml optional: centralize VR_LOG for codex
AGENTS.md how-to-navigate-with-vr guidance for the repo
── one-time setup ──────────────────────────────────────────────────────────
-put vr on PATH: go build -o ~/.local/bin/vr . (repo root, not vrsite/)
+put both on PATH: go build -o ~/.local/bin/vr . (repo root)
+ cd vrsite && go build -o ~/.local/bin/vrsite .
+ (vr is what agents call; vrsite is how you read the trace)
── running a constrained CLAUDE investigation ──────────────────────────────
No repo mutation needed; from the target repo dir:
VR_LOG=/abs/path/trace.jsonl \
- claude -p --settings /Users/goblin/00-projects/0x4200.cafe/vr-agent-logger/constrain/claude-headless-settings.json \
+ claude -p --settings <vr-repo>/constrain/claude-headless-settings.json \
--allowedTools 'Bash(vr)' 'Bash(vr:*)' \
< prompt.txt > report.md
+ (<vr-repo> is wherever this repo lives; the settings file's hook path is
+ absolute into this directory, so it must be spelled out in full)
+
- --allowedTools on the CLI is required headless: allow rules inside
settings are IGNORED until the workspace is trusted (deny rules and the
hook always apply). Interactive use instead: install claude-settings.json
@@ -53,8 +58,12 @@ No repo mutation needed; from the target repo dir:
line-atomic. Same file = one merged timeline for free.
- In the prompt, tell the agent to leave pinned notes
(vr note -f FILE:START-END -t kind "...") — that is the payload.
- - Render the trace afterwards:
- vrsite/vrsite -log trace.jsonl -repo <repo> -out site -title "..."
+ - Read the trace afterwards — serve it, and write notes of your own back
+ into the same log:
+ vrsite -log trace.jsonl -repo <repo> -title "..."
+ or take a static copy to hand around (one change, no server features):
+ vrsite -log trace.jsonl -repo <repo> -out site -title "..."
+ `vrsite -h` explains both, and what a log needs to be worth reading.
── known holes (accepted) ──────────────────────────────────────────────────
Scripting runtimes (python/node/perl) can still open files — uncomment their
diff --git a/vr-log.jsonl b/vr-log.jsonl
index d9af45f..4d1dbe3 100644
--- a/vr-log.jsonl
+++ b/vr-log.jsonl
@@ -51,3 +51,4 @@
{"time":"2026-07-26T21:22:25-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","pattern":"alpenglow_fast_leader_handover|optimistic_parent","matches":35}
{"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","start":1580,"end":1630}
{"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":630,"end":755}
+{"time":"2026-07-30T15:22:34-03:00","op":"note","session":"web:goblin","agent":"goblin","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"vsrmxqunvnxxzylkqlpkzpkmonulkzwn","file":"vr-log.jsonl","start":9,"end":9,"note_id":1,"text":"example note"}
diff --git a/vrsite/app_test.go b/vrsite/app_test.go
index c56fb0d..4db74d4 100644
--- a/vrsite/app_test.go
+++ b/vrsite/app_test.go
@@ -1,12 +1,15 @@
package main
import (
+ "crypto/sha256"
+ "fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
+ "regexp"
"strings"
"testing"
"time"
@@ -23,7 +26,7 @@ const (
func testSite(t *testing.T) *Site {
t.Helper()
- s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB))))
+ s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB))), "")
s.Theme, s.Light = loadTheme("", "", "dark"), loadTheme("", "", "light")
return s
}
@@ -55,7 +58,7 @@ func TestAgentColorsAreBounded(t *testing.T) {
for i := 0; i < maxAgents+3; i++ {
lines = append(lines, `{"time":"2026-07-20T10:00:00Z","op":"note","agent":"a`+string(rune('a'+i))+`","change":"aaa","note_id":1,"text":"x"}`)
}
- s := buildModel("t", parseLog([]byte(logLines(lines...))))
+ s := buildModel("t", parseLog([]byte(logLines(lines...))), "")
if len(s.Agents) != maxAgents+3 {
t.Fatalf("agents = %d", len(s.Agents))
}
@@ -72,6 +75,60 @@ func TestAgentColorsAreBounded(t *testing.T) {
}
}
+// A view of one change holds only what was recorded there — but note ids stay
+// positional over the whole log, so links survive the scope changing.
+func TestModelScopedToAChange(t *testing.T) {
+ otherChange := `{"time":"2026-07-26T10:00:00Z","op":"note","session":"s3","agent":"codex","dir":"/repo","change":"bbb","file":"src/lib.rs","start":5,"end":6,"note_id":1,"text":"at another change"}`
+ otherRead := `{"time":"2026-07-26T09:00:00Z","op":"read","session":"s3","agent":"codex","dir":"/repo","change":"bbb","file":"src/other.rs","start":1,"end":9}`
+ entries := parseLog([]byte(logLines(grepA, readA, noteA, noteB, otherRead, otherChange)))
+
+ all := buildModel("t", entries, "")
+ if len(all.Notes) != 3 || all.NotesTotal != 3 {
+ t.Fatalf("unscoped notes = %d of %d", len(all.Notes), all.NotesTotal)
+ }
+ if all.Change != "aaa" || all.Votes["aaa"] != 4 || all.Votes["bbb"] != 2 {
+ t.Errorf("change %q votes %v", all.Change, all.Votes)
+ }
+ if strings.Join(all.Ranked, ",") != "aaa,bbb" {
+ t.Errorf("ranked = %v, want the most-logged change first", all.Ranked)
+ }
+
+ one := buildModel("t", entries, "aaa")
+ if len(one.Notes) != 2 {
+ t.Fatalf("scoped notes = %d, want the two at aaa", len(one.Notes))
+ }
+ if one.NotesTotal != 3 || one.NotesAt["aaa"] != 2 || one.NotesAt["bbb"] != 1 {
+ t.Errorf("a scoped view must still know the whole log: %d %v", one.NotesTotal, one.NotesAt)
+ }
+ if _, ok := one.Files["src/other.rs"]; ok {
+ t.Error("a file only read at another change leaked into the view")
+ }
+ // the read at bbb covered lines 1-9 of another file; lib.rs keeps only aaa's
+ if f := one.Files["src/lib.rs"]; f == nil || f.Reads != 1 || len(f.Notes) != 1 {
+ t.Errorf("src/lib.rs at aaa = %+v", f)
+ }
+ // ids are the note's position in the log, the same in both views
+ ids := map[string]int{}
+ for _, n := range all.Notes {
+ ids[n.Text] = n.ID
+ }
+ for _, n := range one.Notes {
+ if ids[n.Text] != n.ID {
+ t.Errorf("note %q is id %d scoped, %d unscoped", n.Text, n.ID, ids[n.Text])
+ }
+ }
+ // agent colors come from the whole log, so they do not shuffle when the
+ // scope narrows
+ if len(one.Agents) != len(all.Agents) {
+ t.Errorf("agents = %d scoped, %d unscoped", len(one.Agents), len(all.Agents))
+ }
+ for i := range all.Agents {
+ if one.Agents[i].Name != all.Agents[i].Name || one.Agents[i].Bit != all.Agents[i].Bit {
+ t.Errorf("agent %d differs: %+v vs %+v", i, one.Agents[i], all.Agents[i])
+ }
+ }
+}
+
func TestFilterNotes(t *testing.T) {
s := testSite(t)
for _, tc := range []struct {
@@ -186,13 +243,43 @@ func TestHrefModes(t *testing.T) {
if got := live.Href("code", "src/a.rs"); got != "/code/src/a.rs?rev=xyz" {
t.Errorf("live code href with rev = %q", got)
}
- if got := live.Href("notes"); got != "/notes" {
+ // notes are read against a change too, so the link keeps it
+ if got := live.Href("notes"); got != "/notes?rev=xyz" {
t.Errorf("live notes href = %q", got)
}
+ // "@" is a revset like any other; dropping it would slide the reader back
+ // to the change the log points at
+ if got := (&page{Live: true, Root: "/", Rev: "@"}).Href("files"); got != "/files?rev=%40" {
+ t.Errorf("href browsing @ = %q", got)
+ }
+ wide := &page{Live: true, Root: "/", Kind: "file", Rev: "xyz", Scope: "all", Path: "/code/a.rs"}
+ if got := wide.Href("files"); got != "/files?rev=xyz&scope=all" {
+ t.Errorf("wide files href = %q", got)
+ }
+ if got := wide.ScopeHref("change"); got != "/code/a.rs?rev=xyz" {
+ t.Errorf("narrowing href = %q", got)
+ }
+ if got := wide.BackHref(); got != "/code/a.rs?scope=all" {
+ t.Errorf("back href = %q", got)
+ }
+ if got := (&page{Live: true, Kind: "change", Path: "/change/abc"}).JumpPath(); got != "/files" {
+ t.Errorf("jump path from a change page = %q", got)
+ }
static := &page{Root: "../"}
if got := static.Href("code", "src/a.rs"); got != "../code/src/a.rs.html" {
t.Errorf("static code href = %q", got)
}
+ // a note link carries the note itself, so the page can open focused on it
+ n := &Note{ID: 7, File: "src/a.rs", Start: 12, End: 20}
+ if got := (&page{Live: true, Root: "/"}).NoteHref(n); got != "/code/src/a.rs?note=7#L12" {
+ t.Errorf("live note href = %q", got)
+ }
+ if got := (&page{Live: true, Root: "/", Rev: "xyz"}).NoteHref(n); got != "/code/src/a.rs?rev=xyz&note=7#L12" {
+ t.Errorf("note href at a revision = %q", got)
+ }
+ if got := static.NoteHref(n); got != "../code/src/a.rs.html?note=7#L12" {
+ t.Errorf("static note href = %q", got)
+ }
if got := static.Href("change", "abc"); got != "" {
t.Errorf("static change href = %q, want none", got)
}
@@ -280,6 +367,229 @@ func TestServerPages(t *testing.T) {
}
}
+// The current change is on every page, and the view follows it.
+func TestCurrentChangeRunsTheView(t *testing.T) {
+ sv, logPath, change := testServer(t)
+
+ // a second change with nothing recorded at it
+ empty := filepath.Join(sv.repo, "later.txt")
+ if err := os.WriteFile(empty, []byte("later\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("jj", "new", "-m", "a later change")
+ cmd.Dir, cmd.Env = sv.repo, append(os.Environ(), "JJ_USER=test", "[email protected]", "HOME="+sv.repo)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("jj new: %v\n%s", err, out)
+ }
+ later, err := resolveChange(sv.repo, "@")
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := sv.handler()
+
+ // the default page is the change the log points at, scoped to it
+ body := get(t, h, "/", false).Body.String()
+ for _, want := range []string{short(change), "notes here", `class="changebar`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the index does not show the current change (%q missing)", want)
+ }
+ }
+ if !strings.Contains(body, "second line matters") {
+ t.Error("the note recorded at this change is missing")
+ }
+
+ // browsing a change nothing was recorded at must not look empty: it says so
+ // and falls back to the whole log
+ body = get(t, h, "/?rev="+later, false).Body.String()
+ if !strings.Contains(body, "nothing recorded at this change") {
+ t.Errorf("no explanation for an empty change:\n%s", body[:min(len(body), 900)])
+ }
+ if !strings.Contains(body, "second line matters") {
+ t.Error("the fallback should show the whole log, not nothing")
+ }
+
+ // a note written from a page at that change is recorded there
+ form := url.Values{"text": {"written at the later change"}, "rev": {later}}
+ r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("POST /notes = %d", w.Code)
+ }
+ entries := parseLog(mustRead(t, logPath))
+ if last := entries[len(entries)-1]; last.Change != later {
+ t.Errorf("note recorded at %q, want the change being browsed %q", last.Change, later)
+ }
+
+ // a note written with no change named lands on the one the log points at,
+ // not on the working copy, which has moved on
+ form = url.Values{"text": {"written with nothing named"}}
+ r = httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w = httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ entries = parseLog(mustRead(t, logPath))
+ if last := entries[len(entries)-1]; last.Change != change {
+ t.Errorf("unaimed note recorded at %q, want the log's change %q", last.Change, change)
+ }
+
+ // now that change has a note, scoping to it shows that one alone
+ body = get(t, h, "/notes?rev="+later, false).Body.String()
+ if strings.Contains(body, "second line matters") {
+ t.Error("a note from another change leaked into the scoped view")
+ }
+ if !strings.Contains(body, "written at the later change") {
+ t.Error("the note recorded here is missing")
+ }
+ // and the wide view has both, with the other change's note marked
+ body = get(t, h, "/notes?rev="+later+"&scope=all", false).Body.String()
+ if !strings.Contains(body, "second line matters") || !strings.Contains(body, "written at the later change") {
+ t.Error("scope=all must show every change's notes")
+ }
+ if !strings.Contains(body, "note am1 away") && !strings.Contains(body, "away") {
+ t.Error("a note from another change is not marked as such")
+ }
+ // links keep both the change and the scope
+ if !strings.Contains(body, "rev="+later+"&amp;scope=all") {
+ t.Error("links drop the change or the scope")
+ }
+}
+
+func mustRead(t *testing.T, path string) []byte {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+// ── the server keeps nothing of its own ───────────────────────────────────
+//
+// Every byte it writes goes to the log; everything it holds in memory is a
+// cache of the log and the repo, so a restart changes nothing and a repo that
+// moves underneath it is followed, not remembered.
+
+func repoState(t *testing.T, repo string) string {
+ t.Helper()
+ sum := sha256.New()
+ err := filepath.WalkDir(repo, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ name := d.Name()
+ if d.IsDir() {
+ if name == ".jj" || name == ".git" {
+ return filepath.SkipDir // caches and mtimes of their own
+ }
+ return nil
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(sum, "%s %x\n", path, sha256.Sum256(b))
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // the operation log head: proof we never asked jj to do anything
+ op, err := jjRun(repo, "op", "log", "-n", "1", "--no-graph", "-T", "id")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fmt.Sprintf("%x %s", sum.Sum(nil), op)
+}
+
+func TestServerWritesOnlyTheLog(t *testing.T) {
+ sv, logPath, change := testServer(t)
+ h := sv.handler()
+ before := repoState(t, sv.repo)
+ logBefore := len(mustRead(t, logPath))
+
+ for _, path := range []string{"/", "/files", "/notes", "/code/hello.txt", "/tree",
+ "/jj?revset=all()", "/jj?kind=op", "/change/" + change, "/activity", "/style.css"} {
+ if w := get(t, h, path, false); w.Code != 200 {
+ t.Errorf("GET %s = %d", path, w.Code)
+ }
+ }
+ form := url.Values{"text": {"a note from the browser"}, "file": {"hello.txt"}, "change": {change}}
+ r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ h.ServeHTTP(httptest.NewRecorder(), r)
+
+ if after := repoState(t, sv.repo); after != before {
+ t.Errorf("the repository changed:\n%s\n%s", before, after)
+ }
+ if len(mustRead(t, logPath)) <= logBefore {
+ t.Error("the note did not reach the log")
+ }
+ // nothing else appeared next to the log either
+ dir := filepath.Dir(logPath)
+ names, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) != 1 || filepath.Join(dir, names[0].Name()) != logPath {
+ var got []string
+ for _, n := range names {
+ got = append(got, n.Name())
+ }
+ t.Errorf("the server left files of its own: %v", got)
+ }
+}
+
+var agoRe = regexp.MustCompile(`\d+[smhd] ago|just now`)
+
+func TestServerHoldsNoStateOfItsOwn(t *testing.T) {
+ sv, logPath, change := testServer(t)
+ h := sv.handler()
+ form := url.Values{"text": {"written before the restart"}, "file": {"hello.txt"},
+ "start": {"2"}, "change": {change}}
+ r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ h.ServeHTTP(httptest.NewRecorder(), r)
+
+ // a second server that has only ever seen the log file
+ fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light).handler()
+ for _, path := range []string{"/", "/notes", "/notes?scope=all", "/code/hello.txt",
+ "/files", "/tree", "/change/" + change} {
+ was := agoRe.ReplaceAllString(get(t, h, path, false).Body.String(), "AGO")
+ is := agoRe.ReplaceAllString(get(t, fresh, path, false).Body.String(), "AGO")
+ if was != is {
+ t.Errorf("GET %s differs after a restart — the server is holding state the log does not", path)
+ }
+ }
+}
+
+// A change id is a pointer: editing files rewrites the commit under it. Pages
+// must follow the content, not a cached snapshot of it.
+func TestPagesFollowTheRepo(t *testing.T) {
+ sv, _, _ := testServer(t)
+ h := sv.handler()
+ if body := get(t, h, "/code/hello.txt", false).Body.String(); !strings.Contains(body, "three") {
+ t.Fatalf("the file did not render:\n%s", body[:min(len(body), 400)])
+ }
+ if err := os.WriteFile(filepath.Join(sv.repo, "hello.txt"), []byte("one\ntwo\nrewritten\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // someone else's jj command snapshots it: same change, new commit
+ cmd := exec.Command("jj", "status")
+ cmd.Dir, cmd.Env = sv.repo, append(os.Environ(), "JJ_USER=test", "[email protected]", "HOME="+sv.repo)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("jj status: %v\n%s", err, out)
+ }
+ body := get(t, h, "/code/hello.txt", false).Body.String()
+ if !strings.Contains(body, "rewritten") {
+ t.Error("the page still shows the content the change had when it was first read")
+ }
+ if strings.Contains(body, "three") {
+ t.Error("the old content is still on the page")
+ }
+}
+
func TestNotesFilterOverHTTP(t *testing.T) {
sv, _, _ := testServer(t)
h := sv.handler()
diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js
index 79dc190..104cc85 100644
--- a/vrsite/assets/app.js
+++ b/vrsite/assets/app.js
@@ -138,6 +138,55 @@ for (const ta of document.querySelectorAll(".notform textarea"))
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) ta.form.requestSubmit();
});
+// ── conversation ──────────────────────────────────────────────────────────
+// A session page opened from a note lands on that note, flashed the same way
+// clicking a noted line flashes it.
+const conv = document.querySelector(".conv[data-anchor]");
+if (conv && conv.dataset.anchor) {
+ const here = document.getElementById(conv.dataset.anchor);
+ if (here) {
+ here.scrollIntoView({block: "start"});
+ here.classList.remove("flash"); void here.offsetWidth; here.classList.add("flash");
+ }
+}
+
+// The trip back: a note in a conversation opens the code it was written
+// about. The card is the target, so aim anywhere on it — except at the links
+// it already carries.
+const goTo = w => { location.href = w.dataset.href; };
+for (const w of document.querySelectorAll(".notewrap[data-href]")) {
+ w.addEventListener("click", e => { if (!e.target.closest("a")) goTo(w); });
+ w.addEventListener("keydown", e => {
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goTo(w); }
+ });
+}
+
+// ── arriving at a note ────────────────────────────────────────────────────
+// ?note=N means someone followed a note here rather than opening the file:
+// show what they came for — notes focus (coverage hides the note), the card
+// flashed in the pane, the lines it covers marked.
+const want = new URLSearchParams(location.search).get("note");
+if (want && document.body.dataset.kind === "file" && /^\d+$/.test(want)) {
+ setMode("notes");
+ const card = document.getElementById("note-" + want);
+ if (card) {
+ // the card's own range, not the lines' data-note: a line belongs to the
+ // first note that claimed it, so overlapping notes would mark nothing
+ const from = +card.dataset.start, to = +card.dataset.end || +card.dataset.start;
+ let first = null;
+ for (let n = from; n > 0 && n <= to; n++) {
+ const li = document.getElementById("L" + n);
+ if (!li) continue;
+ li.classList.add("focus");
+ first = first || li;
+ }
+ // after the browser has had its own go at the #L anchor
+ if (first) requestAnimationFrame(() => first.scrollIntoView({block: "center"}));
+ card.scrollIntoView({block: "nearest"});
+ card.classList.remove("flash"); void card.offsetWidth; card.classList.add("flash");
+ }
+}
+
// ── note filters ──────────────────────────────────────────────────────────
// Live pages let the server filter (htmx); the static export filters the
// rendered list in place, so the same form works either way.
diff --git a/vrsite/assets/bash.scm b/vrsite/assets/bash.scm
new file mode 100644
index 0000000..4c7926a
--- /dev/null
+++ b/vrsite/assets/bash.scm
@@ -0,0 +1,21 @@
+; Bash highlights, from tree-sitter/tree-sitter-bash v0.25.1
+; queries/highlights.scm (MIT). Reordered so expansions stay visible inside the
+; strings that contain them, and capture names mapped onto zed syntax keys.
+; Upstream's @embedded captures are dropped: zed paints `embedded` as plain
+; foreground, so they only cost query time.
+
+["$" "&&" "||" ">" ">>" "<" "|" ";" "=" "!"] @operator
+
+["case" "do" "done" "elif" "else" "esac" "export" "fi" "for" "function" "if"
+ "in" "local" "readonly" "select" "then" "unset" "until" "while"] @keyword
+
+[(string) (raw_string) (heredoc_body) (heredoc_start)] @string
+(variable_name) @property
+(file_descriptor) @number
+
+(command_name) @function
+(function_definition name: (word) @function)
+((command (_) @constant)
+ (#match? @constant "^-"))
+
+(comment) @comment
diff --git a/vrsite/assets/c.scm b/vrsite/assets/c.scm
new file mode 100644
index 0000000..789bf93
--- /dev/null
+++ b/vrsite/assets/c.scm
@@ -0,0 +1,36 @@
+; C highlights, from tree-sitter/tree-sitter-c v0.24.2 queries/highlights.scm
+; (MIT). Reordered for later-patterns-win, capture names mapped onto zed syntax
+; keys (preprocessor directives get @preproc rather than upstream's @keyword,
+; which zed themes colour separately). Also loaded ahead of cpp.scm, the way
+; upstream composes the C++ highlights.
+
+["(" ")" "[" "]" "{" "}"] @punctuation.bracket
+[";" "," "."] @punctuation.delimiter
+["--" "-" "-=" "->" "=" "!=" "*" "&" "&&" "+" "++" "+=" "<" "==" ">"
+ "||"] @operator
+
+["break" "case" "const" "continue" "default" "do" "else" "enum" "extern"
+ "for" "if" "inline" "return" "sizeof" "static" "struct" "switch" "typedef"
+ "union" "volatile" "while"] @keyword
+
+["#define" "#elif" "#else" "#endif" "#if" "#ifdef" "#ifndef"
+ "#include"] @preproc
+(preproc_directive) @preproc
+
+[(type_identifier) (primitive_type) (sized_type_specifier)] @type
+(field_identifier) @property
+(statement_identifier) @label
+((identifier) @constant
+ (#match? @constant "^[A-Z][A-Z0-9_]*$"))
+
+(call_expression function: (identifier) @function)
+(call_expression function: (field_expression
+ field: (field_identifier) @function.method))
+(function_declarator declarator: (identifier) @function)
+(preproc_function_def name: (identifier) @function)
+
+(number_literal) @number
+(null) @constant
+[(string_literal) (char_literal) (system_lib_string)] @string
+(escape_sequence) @string.escape
+(comment) @comment
diff --git a/vrsite/assets/cpp.scm b/vrsite/assets/cpp.scm
new file mode 100644
index 0000000..e939a7a
--- /dev/null
+++ b/vrsite/assets/cpp.scm
@@ -0,0 +1,25 @@
+; C++ highlights, from tree-sitter/tree-sitter-cpp v0.23.4
+; queries/highlights.scm (MIT). A supplement loaded after c.scm, as upstream
+; composes it, so these patterns win. Capture names mapped onto zed syntax
+; keys; `this` gets @variable.special to match what rust.scm does for `self`.
+
+["catch" "class" "co_await" "co_return" "co_yield" "constexpr" "constinit"
+ "consteval" "delete" "explicit" "final" "friend" "mutable" "namespace"
+ "noexcept" "new" "override" "private" "protected" "public" "template"
+ "throw" "try" "typename" "using" "concept" "requires" "virtual"] @keyword
+
+(auto) @type
+((namespace_identifier) @type
+ (#match? @type "^[A-Z]"))
+
+(call_expression function: (qualified_identifier
+ name: (identifier) @function))
+(template_function name: (identifier) @function)
+(template_method name: (field_identifier) @function.method)
+(function_declarator declarator: (qualified_identifier
+ name: (identifier) @function))
+(function_declarator declarator: (field_identifier) @function.method)
+
+(raw_string_literal) @string
+(this) @variable.special
+(null "nullptr" @constant)
diff --git a/vrsite/assets/go.scm b/vrsite/assets/go.scm
new file mode 100644
index 0000000..56f676c
--- /dev/null
+++ b/vrsite/assets/go.scm
@@ -0,0 +1,34 @@
+; Go highlights, from tree-sitter/tree-sitter-go v0.25.0 queries/highlights.scm
+; (MIT). Reordered — upstream is written first-pattern-wins, this engine paints
+; later patterns over earlier ones — capture names mapped onto zed syntax keys,
+; and the builtin-function #match? turned into #any-of?, which needs no regex.
+
+["(" ")" "[" "]" "{" "}"] @punctuation.bracket
+[";" "," "." ":"] @punctuation.delimiter
+["--" "-" "-=" ":=" "!" "!=" "..." "*" "*=" "/" "/=" "&" "&&" "&=" "%" "%="
+ "^" "^=" "+" "++" "+=" "<-" "<" "<<" "<<=" "<=" "=" "==" ">" ">=" ">>" ">>="
+ "|" "|=" "||" "~"] @operator
+
+["break" "case" "chan" "const" "continue" "default" "defer" "else"
+ "fallthrough" "for" "func" "go" "goto" "if" "import" "interface" "map"
+ "package" "range" "return" "select" "struct" "switch" "type" "var"] @keyword
+
+(type_identifier) @type
+(field_identifier) @property
+(label_name) @label
+
+(function_declaration name: (identifier) @function)
+(method_declaration name: (field_identifier) @function.method)
+(call_expression function: (identifier) @function)
+(call_expression function: (selector_expression
+ field: (field_identifier) @function.method))
+((call_expression function: (identifier) @function.builtin)
+ (#any-of? @function.builtin "append" "cap" "clear" "close" "complex" "copy"
+ "delete" "imag" "len" "make" "max" "min" "new" "panic" "print" "println"
+ "real" "recover"))
+
+[(int_literal) (float_literal) (imaginary_literal)] @number
+[(true) (false) (nil) (iota)] @constant
+[(interpreted_string_literal) (raw_string_literal) (rune_literal)] @string
+(escape_sequence) @string.escape
+(comment) @comment
diff --git a/vrsite/assets/javascript-params.scm b/vrsite/assets/javascript-params.scm
new file mode 100644
index 0000000..db300e3
--- /dev/null
+++ b/vrsite/assets/javascript-params.scm
@@ -0,0 +1,12 @@
+; JavaScript parameters, from tree-sitter/tree-sitter-javascript v0.25.0
+; queries/highlights-params.scm (MIT), verbatim. Its own file because upstream
+; keeps it out of the TypeScript composition: there a formal_parameters child
+; is always a required_parameter or optional_parameter, so these patterns are
+; not merely dead but rejected by the query compiler as impossible.
+
+(formal_parameters [
+ (identifier) @variable.parameter
+ (array_pattern (identifier) @variable.parameter)
+ (object_pattern [
+ (pair_pattern value: (identifier) @variable.parameter)
+ (shorthand_property_identifier_pattern) @variable.parameter])])
diff --git a/vrsite/assets/javascript.scm b/vrsite/assets/javascript.scm
new file mode 100644
index 0000000..a5c8f01
--- /dev/null
+++ b/vrsite/assets/javascript.scm
@@ -0,0 +1,52 @@
+; JavaScript highlights, from tree-sitter/tree-sitter-javascript v0.25.0
+; queries/highlights.scm (MIT). Reordered for later-patterns-win and capture
+; names mapped onto zed syntax keys. Upstream's @variable.builtin and require()
+; patterns are gone: they lean on (#is-not? local), which no query engine
+; outside nvim evaluates. Also loaded ahead of typescript.scm, the way upstream
+; composes the TypeScript highlights.
+
+["(" ")" "[" "]" "{" "}"] @punctuation.bracket
+[";" (optional_chain) "." ","] @punctuation.delimiter
+["-" "--" "-=" "+" "++" "+=" "*" "*=" "**" "**=" "/" "/=" "%" "%=" "<" "<="
+ "<<" "<<=" "=" "==" "===" "!" "!=" "!==" "=>" ">" ">=" ">>" ">>=" ">>>"
+ ">>>=" "~" "^" "&" "|" "^=" "&=" "|=" "&&" "||" "??" "&&=" "||="
+ "??="] @operator
+
+["as" "async" "await" "break" "case" "catch" "class" "const" "continue"
+ "debugger" "default" "delete" "do" "else" "export" "extends" "finally" "for"
+ "from" "function" "get" "if" "import" "in" "instanceof" "let" "new" "of"
+ "return" "set" "static" "switch" "target" "throw" "try" "typeof" "var"
+ "void" "while" "with" "yield"] @keyword
+
+[(property_identifier) (shorthand_property_identifier)] @property
+
+((identifier) @constructor
+ (#match? @constructor "^[A-Z]"))
+([(identifier) (shorthand_property_identifier)
+ (shorthand_property_identifier_pattern)] @constant
+ (#match? @constant "^[A-Z_][A-Z0-9_]+$"))
+
+(function_expression name: (identifier) @function)
+(function_declaration name: (identifier) @function)
+(method_definition name: (property_identifier) @function.method)
+(pair key: (property_identifier) @function.method
+ value: [(function_expression) (arrow_function)])
+(assignment_expression
+ left: (member_expression property: (property_identifier) @function.method)
+ right: [(function_expression) (arrow_function)])
+(variable_declarator name: (identifier) @function
+ value: [(function_expression) (arrow_function)])
+(assignment_expression left: (identifier) @function
+ right: [(function_expression) (arrow_function)])
+(call_expression function: (identifier) @function)
+(call_expression function: (member_expression
+ property: (property_identifier) @function.method))
+
+[(this) (super)] @variable.special
+(number) @number
+[(true) (false) (null) (undefined)] @constant
+[(string) (template_string)] @string
+(escape_sequence) @string.escape
+(regex) @string.regex
+(template_substitution "${" @punctuation.special "}" @punctuation.special)
+(comment) @comment
diff --git a/vrsite/assets/json.scm b/vrsite/assets/json.scm
new file mode 100644
index 0000000..e82e125
--- /dev/null
+++ b/vrsite/assets/json.scm
@@ -0,0 +1,13 @@
+; JSON highlights, from tree-sitter/tree-sitter-json v0.24.8
+; queries/highlights.scm (MIT). Reordered so object keys repaint the string
+; they are; capture names are already zed syntax keys but for @escape.
+
+["{" "}" "[" "]"] @punctuation.bracket
+[":" ","] @punctuation.delimiter
+
+(number) @number
+[(null) (true) (false)] @constant
+(string) @string
+(pair key: (string) @string.special.key)
+(escape_sequence) @string.escape
+(comment) @comment
diff --git a/vrsite/assets/jsx.scm b/vrsite/assets/jsx.scm
new file mode 100644
index 0000000..708eba2
--- /dev/null
+++ b/vrsite/assets/jsx.scm
@@ -0,0 +1,16 @@
+; JSX highlights, from tree-sitter/tree-sitter-javascript v0.25.0
+; queries/highlights-jsx.scm (MIT), verbatim but for the capture rename.
+; Loaded last for .jsx and .tsx; the plain typescript grammar has no JSX nodes,
+; so .ts must not get this file.
+
+(jsx_opening_element ["<" ">"] @punctuation.bracket)
+(jsx_closing_element ["</" ">"] @punctuation.bracket)
+(jsx_self_closing_element ["<" "/>"] @punctuation.bracket)
+
+(jsx_attribute (property_identifier) @attribute)
+
+; lowercase names are html elements, capitalized ones are components and keep
+; the @constructor javascript.scm gave them
+((jsx_opening_element (identifier) @tag) (#match? @tag "^[a-z][^.]*$"))
+((jsx_closing_element (identifier) @tag) (#match? @tag "^[a-z][^.]*$"))
+((jsx_self_closing_element (identifier) @tag) (#match? @tag "^[a-z][^.]*$"))
diff --git a/vrsite/assets/python.scm b/vrsite/assets/python.scm
new file mode 100644
index 0000000..f64e673
--- /dev/null
+++ b/vrsite/assets/python.scm
@@ -0,0 +1,50 @@
+; Python highlights, from tree-sitter/tree-sitter-python v0.25.0
+; queries/highlights.scm (MIT). Reordered for later-patterns-win, capture names
+; mapped onto zed syntax keys, and the builtin-function #match? turned into
+; #any-of?. The two casing heuristics stay regexes: nothing else can express
+; "CONST_CASE" and "ClassName" over bare identifiers.
+
+["(" ")" "[" "]" "{" "}"] @punctuation.bracket
+[";" "," "." ":"] @punctuation.delimiter
+["-" "-=" "!=" "*" "**" "**=" "*=" "/" "//" "//=" "/=" "&" "&=" "%" "%="
+ "^" "^=" "+" "->" "+=" "<" "<<" "<<=" "<=" "<>" "=" ":=" "==" ">" ">="
+ ">>" ">>=" "|" "|=" "~" "@=" "and" "in" "is" "not" "or" "is not"
+ "not in"] @operator
+
+["as" "assert" "async" "await" "break" "class" "continue" "def" "del" "elif"
+ "else" "except" "exec" "finally" "for" "from" "global" "if" "import" "lambda"
+ "nonlocal" "pass" "print" "raise" "return" "try" "while" "with" "yield"
+ "match" "case"] @keyword
+
+((identifier) @constructor
+ (#match? @constructor "^[A-Z]"))
+((identifier) @constant
+ (#match? @constant "^[A-Z][A-Z_0-9]*$"))
+((identifier) @variable.special
+ (#any-of? @variable.special "self" "cls"))
+
+(type (identifier) @type)
+(attribute attribute: (identifier) @property)
+(function_definition name: (identifier) @function)
+(call function: (identifier) @function)
+(call function: (attribute attribute: (identifier) @function.method))
+((call function: (identifier) @function.builtin)
+ (#any-of? @function.builtin "abs" "all" "any" "ascii" "bin" "bool"
+ "breakpoint" "bytearray" "bytes" "callable" "chr" "classmethod" "compile"
+ "complex" "delattr" "dict" "dir" "divmod" "enumerate" "eval" "exec" "filter"
+ "float" "format" "frozenset" "getattr" "globals" "hasattr" "hash" "help"
+ "hex" "id" "input" "int" "isinstance" "issubclass" "iter" "len" "list"
+ "locals" "map" "max" "memoryview" "min" "next" "object" "oct" "open" "ord"
+ "pow" "print" "property" "range" "repr" "reversed" "round" "set" "setattr"
+ "slice" "sorted" "staticmethod" "str" "sum" "super" "tuple" "type" "vars"
+ "zip" "__import__"))
+
+[(integer) (float)] @number
+[(none) (true) (false)] @constant
+(string) @string
+(escape_sequence) @string.escape
+(interpolation "{" @punctuation.special "}" @punctuation.special)
+
+(decorator "@" @attribute)
+(decorator [(identifier) (attribute)] @attribute)
+(comment) @comment
diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl
index a808203..f57e454 100644
--- a/vrsite/assets/style.css.tmpl
+++ b/vrsite/assets/style.css.tmpl
@@ -73,7 +73,44 @@ nav > header {
}
nav > header a { color: var(--fg); }
nav .brand { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-nav .atrev { margin: 0 0 8px; }
+
+/* ── the current change ────────────────────────────────────────
+ Everything on screen is read at one change: the tree, the code, the
+ coverage, the notes, and anything you write. It says so, always. */
+.changebar {
+ margin-bottom: 10px; padding: 7px 8px;
+ border: 1px solid var(--border); border-left: 3px solid var(--accent);
+ border-radius: 6px; background: var(--bg);
+}
+.changebar.away { border-left-color: var(--note); }
+.changebar .cid { display: flex; align-items: baseline; gap: 6px; }
+.changebar .lbl { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
+.changebar .cid code { font-size: 13px; font-weight: 600; color: var(--fg); }
+.changebar .cid a code { color: var(--accent); }
+.changebar .flags { color: var(--note); font-size: 10px; font-style: italic; margin-left: auto; }
+.changebar .desc {
+ margin: 3px 0 0; font-size: 11.5px; color: var(--muted);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.changebar .counts { margin: 5px 0 0; font-size: 11px; color: var(--muted); }
+.changebar .counts b { color: var(--fg); font-weight: 600; }
+.changebar .away { color: var(--note); }
+.changebar .scope { display: flex; gap: 0; margin-top: 7px; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
+.changebar .scope a {
+ flex: 1; text-align: center; padding: 2px 4px;
+ font-size: 11px; color: var(--muted); text-decoration: none;
+}
+.changebar .scope a:hover { background: var(--hover); color: var(--fg); }
+.changebar .scope a.on { background: var(--hover); color: var(--fg); font-weight: 600; }
+.changebar .revjump { margin-top: 6px; }
+.changebar .revjump input { width: 100%; font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
+.changebar .back { margin: 6px 0 0; font-size: 11px; }
+
+/* a note written at another change: it pins to line numbers that have moved */
+.note.away { border-style: dashed; opacity: 0.82; }
+.note.away:hover { opacity: 1; }
+.atchg.away { color: var(--note); }
+.atchg.away code { color: inherit; }
.find, .filters input, .filters select, .notform input, .notform textarea, .jjform input {
background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: 5px;
@@ -183,6 +220,8 @@ ol.code li {
white-space: pre;
min-height: 1lh;
padding-right: 16px;
+ /* a #L42 jump must clear the sticky header, or the line lands under it */
+ scroll-margin-top: 76px;
}
ol.code li::before {
content: counter(ln);
@@ -222,8 +261,62 @@ aside summary { cursor: pointer; color: var(--muted); }
.note footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 11px; }
.note footer code { color: var(--muted); }
.note .model { opacity: 0.85; }
+.note .conv { margin-left: auto; }
.notecols { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0 14px; }
+/* ── conversation ──────────────────────────────────────────── */
+.convpage { padding: 0 16px 40vh; max-width: 1100px; }
+.turn {
+ display: grid; grid-template-columns: 12ch 8ch minmax(0, 1fr);
+ gap: 0 10px; align-items: baseline;
+ padding: 2px 0 2px 8px; border-left: 2px solid transparent;
+}
+.turn .role {
+ color: var(--muted); font-size: 11px; text-align: right;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.turn time { color: var(--ln); font-size: 11px; font-variant-numeric: tabular-nums; }
+.turn p { margin: 0; line-height: 1.5; white-space: pre-wrap; }
+.turn .what {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.t-say { padding: 8px 0 8px 8px; }
+.t-user { border-left-color: var(--accent); }
+.t-user .role, .t-user p { color: var(--fg); }
+.t-assistant .role { color: var(--cov); }
+.t-thinking p { color: var(--muted); font-style: italic; }
+.t-tool .role { color: var(--note); }
+.t-result .what { color: var(--muted); }
+.t-result.bad .what { color: var(--note); }
+.t-vr { border-left-color: var(--cov); background: color-mix(in srgb, var(--cov) 7%, transparent); }
+.t-vr .role { color: var(--cov); font-weight: 600; }
+/* A note is what you came to the conversation to find, so it breaks the run
+ of log lines: the note color's rail, a tinted band, a card instead of a
+ line. scroll-margin clears the sticky header — the anchored one is
+ scrolled to on load. */
+.t-note {
+ padding: 8px 0 8px 8px;
+ border-left-color: var(--note);
+ background: color-mix(in srgb, var(--note) 7%, transparent);
+}
+.t-note .role { color: var(--note); font-weight: 600; }
+.t-note .role::before { content: "◆ "; font-size: 9px; vertical-align: 1px; }
+.t-note .note { max-width: 640px; margin: 0; scroll-margin-top: 96px; }
+.t-note.here { background: color-mix(in srgb, var(--accent) 12%, transparent); }
+.t-note.here .note { border-color: var(--accent); border-left-color: var(--accent); }
+/* the whole card is the way back to the code; the links inside still work */
+.notewrap { cursor: pointer; }
+.notewrap:hover .note, .notewrap:focus-visible .note {
+ border-color: var(--accent); background: var(--hover);
+}
+.notewrap:focus-visible { outline: none; }
+.notewrap[data-href] .note::after {
+ content: "open in source →"; display: block;
+ margin-top: 6px; color: var(--accent); font-size: 11px; opacity: 0;
+}
+.notewrap:hover .note::after, .notewrap:focus-visible .note::after { opacity: 1; }
+
/* ── live activity ─────────────────────────────────────────── */
.activity { margin-bottom: 10px; }
.actlist { display: flex; flex-direction: column; gap: 3px; }
@@ -279,9 +372,10 @@ aside summary { cursor: pointer; color: var(--muted); }
.asidehead { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
aside .meta { font-size: 11px; }
-/* selection while picking lines to comment on */
-ol.code li.sel { background: color-mix(in srgb, var(--accent) 16%, transparent); }
-ol.code li.sel::before { color: var(--accent); }
+/* lines picked to comment on, and lines you arrived at from a note */
+ol.code li.sel, ol.code li.focus { background: color-mix(in srgb, var(--accent) 16%, transparent); }
+ol.code li.sel::before, ol.code li.focus::before { color: var(--accent); }
+ol.code li.focus { box-shadow: inset 3px 0 var(--accent); }
/* ── jj ────────────────────────────────────────────────────── */
.jjform { display: flex; gap: 6px; align-items: center; padding: 10px 0; }
diff --git a/vrsite/assets/typescript.scm b/vrsite/assets/typescript.scm
new file mode 100644
index 0000000..96cf9c3
--- /dev/null
+++ b/vrsite/assets/typescript.scm
@@ -0,0 +1,15 @@
+; TypeScript highlights, from tree-sitter/tree-sitter-typescript v0.23.2
+; queries/highlights.scm (MIT). A supplement loaded after javascript.scm, as
+; upstream composes it, so these patterns win. Upstream's
+; ((identifier) @type (#match? "^[A-Z]")) is dropped: javascript.scm already
+; paints capitalized identifiers as @constructor, and repainting every one of
+; them as a type costs a second regex per identifier for a worse answer.
+
+["abstract" "declare" "enum" "implements" "interface" "keyof" "namespace"
+ "private" "protected" "public" "type" "readonly" "override"
+ "satisfies"] @keyword
+
+[(type_identifier) (predefined_type)] @type
+(type_arguments "<" @punctuation.bracket ">" @punctuation.bracket)
+(required_parameter (identifier) @variable.parameter)
+(optional_parameter (identifier) @variable.parameter)
diff --git a/vrsite/bench_test.go b/vrsite/bench_test.go
index 8950b8f..c3eac0c 100644
--- a/vrsite/bench_test.go
+++ b/vrsite/bench_test.go
@@ -55,14 +55,163 @@ const MAX_ENTRIES: usize = 4096;
static GREETING: &str = "hello \"quoted\" <world> & friends";
`)
-func synthRust(size int) []byte {
+// One chunk per language whose query leans on a predicate, to keep an eye on
+// what predicates cost: go's builtin list is an #any-of? (a byte compare per
+// call), while python, javascript and C run a casing regex over every
+// identifier they see.
+var langChunks = []struct {
+ path string
+ src []byte
+}{
+ {"bench.go", []byte(`// Package cache keeps entries with hit counts.
+package cache
+
+import (
+ "fmt"
+ "sync"
+)
+
+// Entry is one cached value and its hit count.
+type Entry struct {
+ Key string
+ Value []byte
+ hits uint64
+ tags map[string][]byte
+}
+
+const MaxEntries = 4096
+
+func NewEntry(key string, value []byte) *Entry {
+ return &Entry{Key: key, Value: value, tags: make(map[string][]byte)}
+}
+
+// Touch records a hit and returns the running total.
+func (e *Entry) Touch(mu *sync.Mutex) uint64 {
+ mu.Lock()
+ defer mu.Unlock()
+ e.hits++
+ if e.hits%100 == 0 {
+ fmt.Printf("entry %q hit %d times \"escaped\"\n", e.Key, e.hits)
+ }
+ return e.hits
+}
+
+func (e *Entry) Lookup(keys []string) ([]byte, bool) {
+ for _, k := range keys {
+ if v, ok := e.tags[k]; ok && len(v) > 0 {
+ return v, true
+ }
+ }
+ return nil, false
+}
+`)},
+ {"bench.py", []byte(`"""Module documentation with some text."""
+import sys
+from collections import OrderedDict
+
+MAX_ENTRIES = 4096
+
+
+class CacheEntry:
+ """A cached entry with a hit count."""
+
+ def __init__(self, key, value):
+ self.key = key
+ self.value = value
+ self.hits = 0
+ self.tags = OrderedDict()
+
+ @property
+ def stale(self):
+ return self.hits > MAX_ENTRIES
+
+ def touch(self):
+ self.hits += 1
+ if self.hits % 100 == 0:
+ print(f"entry {self.key} hit {self.hits} times \"escaped\"", file=sys.stderr)
+ return self.hits
+
+ def lookup(self, keys):
+ for k in keys:
+ if k in self.tags:
+ return self.tags[k], True
+ return None, False
+`)},
+ {"bench.js", []byte(`// Cache entries with hit counts.
+import { EventEmitter } from "node:events";
+
+const MAX_ENTRIES = 4096;
+
+export class CacheEntry extends EventEmitter {
+ constructor(key, value) {
+ super();
+ this.key = key;
+ this.value = value;
+ this.hits = 0;
+ this.tags = new Map();
+ }
+
+ get stale() {
+ return this.hits > MAX_ENTRIES;
+ }
+
+ touch() {
+ this.hits += 1;
+ if (this.hits % 100 === 0) {
+ console.log(` + "`entry ${this.key} hit ${this.hits} times \"escaped\"`" + `);
+ }
+ return this.hits;
+ }
+
+ lookup(keys) {
+ return keys.map((k) => this.tags.get(k)).find((v) => v !== undefined) ?? null;
+ }
+}
+`)},
+ {"bench.c", []byte(`/* Cache entries with hit counts. */
+#include <stdio.h>
+#include <string.h>
+
+#define MAX_ENTRIES 4096
+
+struct cache_entry {
+ const char *key;
+ unsigned char *value;
+ unsigned long hits;
+};
+
+static struct cache_entry *entry_new(const char *key, unsigned char *value) {
+ static struct cache_entry e;
+ e.key = key;
+ e.value = value;
+ e.hits = 0;
+ return &e;
+}
+
+unsigned long entry_touch(struct cache_entry *e) {
+ e->hits++;
+ if (e->hits % 100 == 0) {
+ fprintf(stderr, "entry %s hit %lu times \"escaped\"\n", e->key, e->hits);
+ }
+ return e->hits;
+}
+
+int entry_lookup(struct cache_entry *e, const char *key) {
+ return e->key != NULL && strcmp(e->key, key) == 0;
+}
+`)},
+}
+
+func synth(chunk []byte, size int) []byte {
var b bytes.Buffer
for b.Len() < size {
- b.Write(rustChunk)
+ b.Write(chunk)
}
return b.Bytes()
}
+func synthRust(size int) []byte { return synth(rustChunk, size) }
+
var sizes = []int{128 << 10, 1 << 20, 4 << 20}
func benchHL(b *testing.B) *highlighter {
@@ -85,6 +234,21 @@ func BenchmarkClassify(b *testing.B) {
}
}
+// the same work for the languages whose queries evaluate predicates
+func BenchmarkClassifyLangs(b *testing.B) {
+ hl := benchHL(b)
+ for _, c := range langChunks {
+ src := synth(c.src, 1<<20)
+ b.Run(c.path, func(b *testing.B) {
+ b.SetBytes(int64(len(src)))
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ hl.classify(c.path, src)
+ }
+ })
+ }
+}
+
// classify + full per-line HTML emit
func BenchmarkRender(b *testing.B) {
hl := benchHL(b)
diff --git a/vrsite/go.mod b/vrsite/go.mod
index bf04dfe..cb9dd9e 100644
--- a/vrsite/go.mod
+++ b/vrsite/go.mod
@@ -4,7 +4,15 @@ go 1.26.5
require (
github.com/tree-sitter/go-tree-sitter v0.25.0
+ github.com/tree-sitter/tree-sitter-bash v0.25.1
+ github.com/tree-sitter/tree-sitter-c v0.24.2
+ github.com/tree-sitter/tree-sitter-cpp v0.23.4
+ github.com/tree-sitter/tree-sitter-go v0.25.0
+ github.com/tree-sitter/tree-sitter-javascript v0.25.0
+ github.com/tree-sitter/tree-sitter-json v0.24.8
+ github.com/tree-sitter/tree-sitter-python v0.25.0
github.com/tree-sitter/tree-sitter-rust v0.24.2
+ github.com/tree-sitter/tree-sitter-typescript v0.23.2
)
require github.com/mattn/go-pointer v0.0.1 // indirect
diff --git a/vrsite/go.sum b/vrsite/go.sum
index b2466d9..af50473 100644
--- a/vrsite/go.sum
+++ b/vrsite/go.sum
@@ -8,29 +8,33 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
-github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c=
-github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw=
+github.com/tree-sitter/tree-sitter-bash v0.25.1 h1:ZD3MK4oDB5lAsFztqbdcyYEd24pxDtx3g9UOWA062rE=
+github.com/tree-sitter/tree-sitter-bash v0.25.1/go.mod h1:AksQ6zE+sP9hnp7mKTMT7Q+CwpthV7VGQLXvweVXz9U=
+github.com/tree-sitter/tree-sitter-c v0.24.2 h1:nW+M6BnPUa/fBwks8nqf1NiVvu7nltaC+5bR/lTtJCs=
+github.com/tree-sitter/tree-sitter-c v0.24.2/go.mod h1:/SpJlv2BuiCgFA5xvtgukFGi51WxctByPUGDxPl60fc=
github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w=
github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg=
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ=
-github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA=
-github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
+github.com/tree-sitter/tree-sitter-go v0.25.0 h1:cEB0Q3LHgZtS+ECHx9wcP7AwzoOddJFQCVmytX42cVU=
+github.com/tree-sitter/tree-sitter-go v0.25.0/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc=
github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og=
github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig=
github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320=
-github.com/tree-sitter/tree-sitter-javascript v0.23.1 h1:1fWupaRC0ArlHJ/QJzsfQ3Ibyopw7ZfQK4xXc40Zveo=
-github.com/tree-sitter/tree-sitter-javascript v0.23.1/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
+github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
+github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ=
github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo=
github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8=
github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74=
-github.com/tree-sitter/tree-sitter-python v0.23.6 h1:qHnWFR5WhtMQpxBZRwiaU5Hk/29vGju6CVtmvu5Haas=
-github.com/tree-sitter/tree-sitter-python v0.23.6/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
+github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0=
+github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo=
github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA=
github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0=
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
+github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
+github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/vrsite/highlight_test.go b/vrsite/highlight_test.go
new file mode 100644
index 0000000..b565785
--- /dev/null
+++ b/vrsite/highlight_test.go
@@ -0,0 +1,177 @@
+package main
+
+import (
+ "strings"
+ "sync"
+ "testing"
+
+ sitter "github.com/tree-sitter/go-tree-sitter"
+)
+
+func testHighlighter(t *testing.T) *highlighter {
+ t.Helper()
+ return newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))
+}
+
+// One snippet per language, mapping a piece of its source to the class the One
+// theme implies for it. Everything asserted here is a key both One Dark and
+// One Light colour differently from plain foreground.
+var samples = []struct {
+ path string
+ src string
+ want map[string]string
+}{
+ {"a.rs", "// note\nfn main() { let s = \"hi\"; }\n",
+ map[string]string{"fn": "keyword", `"hi"`: "string", "// note": "comment", "main": "function"}},
+ {"a.go", "// note\nfunc main() { s := \"hi\"; _ = s }\n",
+ map[string]string{"func": "keyword", `"hi"`: "string", "// note": "comment", "main": "function"}},
+ {"a.py", "# note\nclass Frame:\n def draw(self):\n widget = Widget\n return len(\"hi\")\n",
+ map[string]string{"class": "keyword", `"hi"`: "string", "# note": "comment",
+ "draw": "function", "len": "function", "Frame": "constructor",
+ "Widget": "constructor", "self": "variable-special", "widget": ""}},
+ {"a.js", "// note\nfunction main() { const s = \"hi\"; return s; }\n",
+ map[string]string{"function": "keyword", `"hi"`: "string", "// note": "comment", "main": "function"}},
+ {"a.ts", "// note\nfunction main(n: number): string { return \"hi\"; }\n",
+ map[string]string{"function": "keyword", `"hi"`: "string", "// note": "comment",
+ "number": "type", "main": "function"}},
+ {"a.tsx", "// note\nconst e = <div id=\"x\" />;\n",
+ map[string]string{"const": "keyword", `"x"`: "string", "// note": "comment", "id": "attribute"}},
+ {"a.c", "// note\n#include <stdio.h>\nint main(void) { const char *s = \"hi\"; return MAX; }\n",
+ map[string]string{"const": "keyword", `"hi"`: "string", "// note": "comment",
+ "int": "type", "main": "function", "#include": "preproc",
+ "<stdio.h>": "string", "MAX": "constant"}},
+ {"a.cc", "// note\nnamespace n { class C { const char *s = \"hi\"; }; }\n",
+ map[string]string{"namespace": "keyword", `"hi"`: "string", "// note": "comment", "char": "type"}},
+ // .h goes to the C++ grammar, but c.scm's patterns still have to reach it
+ {"a.h", "// note\ntemplate <class T> struct Box { T v; };\n",
+ map[string]string{"template": "keyword", "struct": "keyword", "// note": "comment", "T": "type"}},
+ {"a.json", "{\n \"a\": \"hi\",\n \"b\": 12\n}\n",
+ map[string]string{`"hi"`: "string", `"a"`: "string-special", "12": "number"}},
+ {"a.sh", "# note\nif true; then\n ls -l \"$dir\"\nfi\n",
+ map[string]string{"if": "keyword", "# note": "comment", "ls": "function",
+ "-l": "constant", "dir": "property"}},
+}
+
+// classOf reports the single class covering every byte of text's first
+// occurrence in src, or "" when the run is unclassified or not uniform.
+func classOf(hl *highlighter, path, src, text string) string {
+ classes := hl.classify(path, []byte(src))
+ i := strings.Index(src, text)
+ if i < 0 {
+ return ""
+ }
+ for j := i + 1; j < i+len(text); j++ {
+ if classes[j] != classes[i] {
+ return ""
+ }
+ }
+ return hl.classNames[classes[i]]
+}
+
+func TestClassifyPerLanguage(t *testing.T) {
+ hl := testHighlighter(t)
+ for _, s := range samples {
+ for text, want := range s.want {
+ if got := classOf(hl, s.path, s.src, text); got != want {
+ t.Errorf("%s: %q classified %q, want %q", s.path, text, got, want)
+ }
+ }
+ }
+}
+
+func TestClassifyUnknownExtension(t *testing.T) {
+ hl := testHighlighter(t)
+ // text that every grammar would happily highlight, under paths none claims
+ src := []byte("// note\nfn main() { let s = \"hi\"; }\n")
+ for _, path := range []string{"notes.txt", "README.md", "Makefile", "a/b.unknown", ".gitignore", ""} {
+ classes := hl.classify(path, src)
+ if len(classes) != len(src) {
+ t.Fatalf("%q: got %d classes for %d bytes", path, len(classes), len(src))
+ }
+ for i, c := range classes {
+ if c != 0 {
+ t.Fatalf("%q: byte %d classified %q, want plain text", path, i, hl.classNames[c])
+ }
+ }
+ }
+}
+
+// Every predicate a vendored query uses has to be one go-tree-sitter actually
+// evaluates in QueryMatches.Next. Anything else lands in these two buckets,
+// where it is parsed, ignored, and silently paints matches it should not.
+func TestQueriesUseOnlyEvaluatedPredicates(t *testing.T) {
+ hl := testHighlighter(t)
+ for _, g := range grammars {
+ l := hl.byExt[g.exts[0]]
+ name := strings.Join(g.scms, "+")
+ for i := uint(0); i < l.query.PatternCount(); i++ {
+ for _, p := range l.query.GeneralPredicates(i) {
+ t.Errorf("%s pattern %d: #%s is not evaluated", name, i, p.Operator)
+ }
+ for _, p := range l.query.PropertyPredicates(i) {
+ t.Errorf("%s pattern %d: #is? %s is not evaluated", name, i, p.Property.Key)
+ }
+ }
+ }
+}
+
+// A query that fails to compile is fatal at startup, so report the real error
+// here rather than letting the process exit out from under the test binary.
+func TestQueriesCompile(t *testing.T) {
+ for _, g := range grammars {
+ var scm strings.Builder
+ for _, name := range g.scms {
+ b, err := tfs.ReadFile("assets/" + name + ".scm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ scm.Write(b)
+ }
+ if _, err := sitter.NewQuery(sitter.NewLanguage(g.lang()), scm.String()); err != nil {
+ t.Errorf("%s: %v", strings.Join(g.scms, "+"), err)
+ }
+ }
+}
+
+// Class names are theme syntax keys, so css.go's .s-<key> rules — generated
+// from the union of both themes' keys — already cover every new language.
+func TestClassNamesHaveTheme(t *testing.T) {
+ dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light")
+ hl := newHighlighter(dark, light)
+ styled := map[string]bool{}
+ for _, th := range []*Theme{dark, light} {
+ for k := range th.Syntax {
+ styled[strings.ReplaceAll(k, ".", "-")] = true
+ }
+ }
+ for _, cls := range hl.classNames[1:] {
+ if !styled[cls] {
+ t.Errorf("class %q has no .s-%s rule in either theme", cls, cls)
+ }
+ }
+}
+
+func TestClassifyIsRaceFree(t *testing.T) {
+ hl := testHighlighter(t)
+ want := make([][]int16, len(samples))
+ for i, s := range samples {
+ want[i] = hl.classify(s.path, []byte(s.src))
+ }
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i, s := range samples {
+ got := hl.classify(s.path, []byte(s.src))
+ for j := range got {
+ if got[j] != want[i][j] {
+ t.Errorf("%s: byte %d classified %d, want %d", s.path, j, got[j], want[i][j])
+ return
+ }
+ }
+ }
+ }()
+ }
+ wg.Wait()
+}
diff --git a/vrsite/jj.go b/vrsite/jj.go
index 55f4795..bb95d83 100644
--- a/vrsite/jj.go
+++ b/vrsite/jj.go
@@ -89,6 +89,24 @@ func resolveChange(repo, rev string) (string, error) {
return id, nil
}
+// changeBrief resolves a revset to the one change it names, with everything a
+// page says about it. One call answers both "which change" and "which commit
+// is under it right now" — the second is what content is cached against, since
+// a change id is a pointer and the commit beneath it is rewritten on every
+// edit.
+func changeBrief(repo, rev string) (Row, error) {
+ rows, err := LogRows(repo, rev, "", 1)
+ if err != nil {
+ return Row{}, err
+ }
+ for _, r := range rows {
+ if !r.Blank() {
+ return r, nil
+ }
+ }
+ return Row{}, fmt.Errorf("revset %q matches no change", rev)
+}
+
func fileList(repo, rev string) ([]string, error) {
out, err := jjRun(repo, "file", "list", "-r", rev)
if err != nil {
diff --git a/vrsite/main.go b/vrsite/main.go
index 0a525be..5f9b1ef 100644
--- a/vrsite/main.go
+++ b/vrsite/main.go
@@ -21,6 +21,7 @@ import (
"os"
"os/user"
"path/filepath"
+ "strings"
"time"
)
@@ -32,6 +33,45 @@ func fatal(format string, a ...any) {
os.Exit(1)
}
+const usageText = `vrsite — the reading room for vr trace logs.
+
+vr logs every file a coding agent reads, greps and notes, one JSON line each,
+keyed to the jj change the code was read at. vrsite serves that log: the repo
+tree with per-agent read coverage, syntax-highlighted sources, the notes agents
+left, the conversation each note was written in, and jj queries over the repo.
+
+usage:
+ vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME]
+ serve it, at http://127.0.0.1:4200 by default
+ vrsite -log FILE -repo DIR -out DIR [-watch]
+ write a static copy instead — no server, so no comments, no live
+ activity, no jj queries; the focus toggle, filters and file finder
+ still work, and it holds the one change the log points at
+
+starting on a new project:
+ 1. the project must be a jj repo — in an existing git one:
+ jj git init --colocate
+ 2. give the agents vr and one log for the repo:
+ export VR_LOG=$PWD/vr-log.jsonl
+ and tell them to read with it; constrain/AGENTS.md in the vr repo is a
+ drop-in brief that does, and constrain/ can also enforce it
+ 3. let them work, then:
+ vrsite -log $PWD/vr-log.jsonl -repo $PWD
+
+A log belongs to one repository. Entries carry the jj change id they were
+recorded at, and vrsite reads the code at those changes, so a log from another
+repo has nothing to show here. Point -repo at the repository the agents ran in.
+
+Notes written in the browser are appended to the same log, under -user, at the
+change being read. Nothing else is ever written: the repository is only read,
+and the app keeps no state of its own.
+
+Session transcripts (the conversation behind a note) are looked up by session
+id under -transcripts; Claude Code and Codex are found automatically.
+
+flags:
+`
+
func main() {
logPath := flag.String("log", "", "vr trace log (jsonl)")
repo := flag.String("repo", "", "jj repo the trace was recorded in")
@@ -43,13 +83,25 @@ func main() {
lightPath := flag.String("light-theme", defaultTheme("light"), "zed theme json for the light scheme (default: discovered, else embedded One Light)")
lightName := flag.String("light-theme-name", "", "theme name inside the light family")
title := flag.String("title", "vr trace", "site title")
+ roots := flag.String("transcripts", strings.Join(defaultTranscriptRoots(), ","),
+ "comma-separated roots to look for harness session transcripts in")
watch := flag.Bool("watch", false, "with -out: watch the trace log and rebuild after changes settle")
debounce := flag.Duration("debounce", 10*time.Minute, "quiet period before a watched rebuild")
+ flag.Usage = func() {
+ fmt.Fprint(flag.CommandLine.Output(), usageText)
+ flag.PrintDefaults()
+ }
flag.Parse()
if *logPath == "" || *repo == "" {
- fatal("usage: vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME]\n" +
- " vrsite -log FILE -repo DIR -out DIR [-watch] [-debounce 10m]\n" +
- " shared: [-theme zed.json] [-theme-name N] [-light-theme f] [-title T]")
+ missing := "-log and -repo are required"
+ if *logPath != "" {
+ missing = "-repo is required: the repository the log was recorded in"
+ } else if *repo != "" {
+ missing = "-log is required: the jsonl file vr appends to"
+ }
+ fmt.Fprintf(os.Stderr, "vrsite: %s\n\n", missing)
+ flag.Usage()
+ os.Exit(2)
}
repoAbs, err := filepath.Abs(*repo)
if err != nil {
@@ -65,7 +117,7 @@ func main() {
if *watch {
fatal("-watch is for -out builds; the served app already follows the log")
}
- serve(*addr, *logPath, repoAbs, *title, username(*who), theme, light)
+ serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light)
return
}
build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light) }
diff --git a/vrsite/model.go b/vrsite/model.go
index 2cafd75..678d5ee 100644
--- a/vrsite/model.go
+++ b/vrsite/model.go
@@ -74,22 +74,37 @@ type Site struct {
Title string
Repo string
Change string // the change most entries were logged at
+ Only string // the change this view is scoped to ("" = the whole log)
Agents []*Agent
Files map[string]*File // traced files
Order []string // traced paths, sorted
All []*File // every file in the repo at the site change
- Notes []*Note
- Entries []Entry
- Theme *Theme // dark
+ Notes []*Note // in scope
+ Entries []Entry // the whole log, scope or no scope
+ Theme *Theme // dark
Light *Theme
+
+ // counted over the whole log, so a scoped view can still say what it is
+ // not showing
+ NotesTotal int
+ NotesAt map[string]int // change -> notes recorded there
+ Votes map[string]int // change -> entries recorded there
+ Ranked []string // changes, most-logged first
}
// buildModel projects log entries onto agents, files and notes. It never
// touches the repo, so it is cheap enough to redo on every log change.
-func buildModel(title string, entries []Entry) *Site {
- s := &Site{Title: title, Files: map[string]*File{}, Entries: entries}
+//
+// only, when set, keeps just what was recorded at that change: a read logged
+// against another change points at line numbers that have since moved, so a
+// view of one change must not inherit them. Two things stay whole-log
+// regardless — agent colors, so they do not shuffle when the scope changes,
+// and note ids, so a link to a note survives it.
+func buildModel(title string, entries []Entry, only string) *Site {
+ s := &Site{Title: title, Only: only, Files: map[string]*File{}, Entries: entries,
+ NotesAt: map[string]int{}, Votes: map[string]int{}}
agents := map[string]*Agent{}
- changeVotes := map[string]int{}
+ noteN := 0
for _, e := range entries {
if e.Agent == "" {
e.Agent = "unknown"
@@ -105,7 +120,15 @@ func buildModel(title string, entries []Entry) *Site {
s.Agents = append(s.Agents, a)
}
if e.Change != "" {
- changeVotes[e.Change]++
+ s.Votes[e.Change]++
+ }
+ if e.Op == "note" {
+ noteN++
+ s.NotesTotal++
+ s.NotesAt[e.Change]++
+ }
+ if only != "" && e.Change != only {
+ continue
}
switch e.Op {
case "read":
@@ -125,7 +148,7 @@ func buildModel(title string, entries []Entry) *Site {
case "note":
a.Notes++
at := parseTime(e.Time)
- n := &Note{ID: len(s.Notes) + 1, NoteID: e.NoteID, Agent: a, Session: e.Session,
+ n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session,
Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change,
Rev: e.Rev, Start: e.Start, End: e.End, Kind: e.Type, Text: e.Text,
At: at, When: stamp(at), Ago: ago(at)}
@@ -136,10 +159,19 @@ func buildModel(title string, entries []Entry) *Site {
}
}
}
- for c, n := range changeVotes {
- if s.Change == "" || n > changeVotes[s.Change] {
- s.Change = c
+ for c := range s.Votes {
+ s.Ranked = append(s.Ranked, c)
+ }
+ // most-logged first; ties by id so the pick is stable across rebuilds
+ sort.Slice(s.Ranked, func(i, j int) bool {
+ a, b := s.Ranked[i], s.Ranked[j]
+ if s.Votes[a] != s.Votes[b] {
+ return s.Votes[a] > s.Votes[b]
}
+ return a < b
+ })
+ if len(s.Ranked) > 0 {
+ s.Change = s.Ranked[0]
}
// drop grep-only "files" that are really path filters (dirs, non-files)
for p, f := range s.Files {
diff --git a/vrsite/page.go b/vrsite/page.go
index 71a8343..2974eec 100644
--- a/vrsite/page.go
+++ b/vrsite/page.go
@@ -16,16 +16,29 @@ import (
type page struct {
Site *Site
Title string
- Kind string // index | file | files | notes | jj | change
+ Kind string // index | file | files | notes | jj | change | session
Root string // static: relative prefix; live: "/"
Live bool
- Rev string // revision being browsed ("" = the site change)
- ChangeID string // resolved change id for Rev
+ Rev string // revision being browsed ("" = the change the log points at)
+ ChangeID string // resolved change id for Rev — what pages name and link
+ Commit string // the commit under it — what content is read and cached at
+ Default string // the change the log points at
+ Path string // this request's path, so the change bar can re-aim it
Current string // current file path, for the tree
User string // who web comments are attributed to
- Tree template.HTML
- File *File
+ // what this view is scoped to. Scope is "change" (only what was recorded
+ // at ChangeID) or "all"; ScopeEmpty means "change" was asked for but
+ // nothing was ever recorded there, so the whole log is showing instead.
+ Scope string
+ ScopeEmpty bool
+ Here int // notes recorded at this change
+ Elsewhere int // notes recorded at any other change
+ ChangeInfo *Row
+
+ Tree template.HTML
+ File *File
+ Files []*File // every file at this change, for counts
Notes []*Note
Filter NoteFilter
@@ -36,6 +49,7 @@ type page struct {
JJ *JJView
Change *Change
+ Conv *Conv
Err string
}
@@ -49,15 +63,54 @@ type JJView struct {
Err string
}
-func (p *page) revQuery(first bool) string {
- if p.Rev == "" || p.Rev == "@" {
+// params carries the two things every link must not lose: which change you
+// are looking at, and how wide the view is.
+func (p *page) params() string { return p.paramsWith(p.Scope) }
+
+func (p *page) paramsWith(scope string) string {
+ var q []string
+ // "@" is a revset like any other now that the default change is the log's,
+ // so dropping it here would slide the reader off the change they picked
+ if p.Rev != "" {
+ q = append(q, "rev="+url.QueryEscape(p.Rev))
+ }
+ if scope == "all" {
+ q = append(q, "scope=all")
+ }
+ if len(q) == 0 {
return ""
}
- sep := "&"
- if first {
- sep = "?"
+ return "?" + strings.Join(q, "&")
+}
+
+// ScopeHref is this same page seen with the other scope.
+func (p *page) ScopeHref(scope string) string {
+ path := p.Path
+ if path == "" {
+ path = "/"
+ }
+ return path + p.paramsWith(scope)
+}
+
+// BackHref is this page again at the change the log points at.
+func (p *page) BackHref() string {
+ q := ""
+ if p.Scope == "all" {
+ q = "?scope=all"
+ }
+ return p.JumpPath() + q
+}
+
+// JumpPath is where the change picker submits: your own page when it is one
+// that can be read at any change, the file tree otherwise.
+func (p *page) JumpPath() string {
+ switch p.Kind {
+ case "index", "file", "files", "notes":
+ if p.Path != "" {
+ return p.Path
+ }
}
- return sep + "rev=" + url.QueryEscape(p.Rev)
+ return "/files"
}
// Href maps a logical destination to a URL for this page's mode. Kinds the
@@ -70,19 +123,21 @@ func (p *page) Href(kind string, arg ...string) string {
if p.Live {
switch kind {
case "":
- return "/" + p.revQuery(true)
+ return "/" + p.params()
case "notes":
- return "/notes"
+ return "/notes" + p.params()
case "files":
- return "/files" + p.revQuery(true)
+ return "/files" + p.params()
case "tree":
- return "/tree" + p.revQuery(true)
+ return "/tree" + p.params()
case "jj":
return "/jj"
case "code":
- return "/code/" + escPath(a) + p.revQuery(true)
+ return "/code/" + escPath(a) + p.params()
case "change":
return "/change/" + a
+ case "session":
+ return "/session/" + escPath(a)
case "asset":
return "/" + a
}
@@ -118,12 +173,31 @@ func escPath(p string) string {
return strings.Join(parts, "/")
}
+// ConvHref opens the conversation a note was written in, anchored on it. It
+// needs the transcripts on this machine, so the static export has none — and
+// the conversation itself does not link back to where you already are.
+func (p *page) ConvHref(n *Note) string {
+ if !p.Live || p.Kind == "session" || n.Session == "" || safeSession(n.Session) != nil {
+ return ""
+ }
+ id := strconv.Itoa(n.ID)
+ return p.Href("session", n.Session) + "?note=" + id + "#note-" + id
+}
+
// NoteHref points at the noted line, or the note list when it is unpinned.
+// The ?note= is what turns a jump into an arrival: the page opens in notes
+// focus with the card flashed and its lines marked (see app.js).
func (p *page) NoteHref(n *Note) string {
if n.File == "" {
return p.Href("notes")
}
+ id := strconv.Itoa(n.ID)
h := p.Href("code", n.File)
+ sep := "?"
+ if strings.Contains(h, "?") {
+ sep = "&"
+ }
+ h += sep + "note=" + id
if n.Start > 0 {
h += "#L" + strconv.Itoa(n.Start)
}
diff --git a/vrsite/render.go b/vrsite/render.go
index de21fc5..f18c3a8 100644
--- a/vrsite/render.go
+++ b/vrsite/render.go
@@ -5,12 +5,22 @@ package main
import (
"bytes"
"html/template"
+ "path/filepath"
"sort"
"strconv"
"strings"
+ "unsafe"
sitter "github.com/tree-sitter/go-tree-sitter"
+ tsbash "github.com/tree-sitter/tree-sitter-bash/bindings/go"
+ tsc "github.com/tree-sitter/tree-sitter-c/bindings/go"
+ tscpp "github.com/tree-sitter/tree-sitter-cpp/bindings/go"
+ tsgo "github.com/tree-sitter/tree-sitter-go/bindings/go"
+ tsjs "github.com/tree-sitter/tree-sitter-javascript/bindings/go"
+ tsjson "github.com/tree-sitter/tree-sitter-json/bindings/go"
+ tspython "github.com/tree-sitter/tree-sitter-python/bindings/go"
tsrust "github.com/tree-sitter/tree-sitter-rust/bindings/go"
+ tsts "github.com/tree-sitter/tree-sitter-typescript/bindings/go"
)
// Lines per content-visibility chunk: the browser skips layout and paint of
@@ -179,10 +189,41 @@ func escapeTo(b *strings.Builder, s []byte) {
// ── highlighting ──────────────────────────────────────────────────────────
-type highlighter struct {
+// grammars lists every language the site can highlight: the extensions it
+// claims, the vendored queries that paint it, and the cgo grammar. Query files
+// concatenate the way upstream composes them — C's patterns underpin C++,
+// JavaScript's underpin TypeScript — and the concatenation keeps the
+// later-patterns-win order, so the supplement wins where both match.
+var grammars = []struct {
+ exts []string
+ scms []string
+ lang func() unsafe.Pointer
+}{
+ {[]string{".rs"}, []string{"rust"}, tsrust.Language},
+ {[]string{".go"}, []string{"go"}, tsgo.Language},
+ {[]string{".py", ".pyi"}, []string{"python"}, tspython.Language},
+ {[]string{".js", ".mjs", ".cjs", ".jsx"}, []string{"javascript", "javascript-params", "jsx"}, tsjs.Language},
+ {[]string{".ts", ".mts", ".cts"}, []string{"javascript", "typescript"}, tsts.LanguageTypescript},
+ {[]string{".tsx"}, []string{"javascript", "typescript", "jsx"}, tsts.LanguageTSX},
+ {[]string{".c"}, []string{"c"}, tsc.Language},
+ // .h is C++'s upstream claim, and the C++ grammar is a superset: C headers
+ // parse the same under it, C++ headers only parse under it
+ {[]string{".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"}, []string{"c", "cpp"}, tscpp.Language},
+ {[]string{".json"}, []string{"json"}, tsjson.Language},
+ {[]string{".sh", ".bash"}, []string{"bash"}, tsbash.Language},
+}
+
+// language is one grammar's share of the highlighter. Everything here is built
+// at startup and never written again, which is what lets classify run from any
+// number of goroutines at once.
+type language struct {
lang *sitter.Language
query *sitter.Query
captureCls []int16 // capture index -> class id
+}
+
+type highlighter struct {
+ byExt map[string]*language
classNames []string
}
@@ -213,72 +254,91 @@ func nearFg(t *Theme, name string) bool {
}
func newHighlighter(dark, light *Theme) *highlighter {
- h := &highlighter{classNames: []string{""}}
- h.lang = sitter.NewLanguage(tsrust.Language())
- scm, _ := tfs.ReadFile("assets/rust.scm")
- q, qerr := sitter.NewQuery(h.lang, string(scm))
- if qerr != nil {
- fatal("rust query: %v", qerr)
- }
- h.query = q
- for _, name := range q.CaptureNames() {
- // skip captures that both themes paint (nearly) as foreground —
- // punctuation and friends are the bulk of all spans
- if nearFg(dark, name) && nearFg(light, name) {
- h.captureCls = append(h.captureCls, 0)
- continue
+ h := &highlighter{byExt: map[string]*language{}, classNames: []string{""}}
+ for _, g := range grammars {
+ var scm strings.Builder
+ for _, name := range g.scms {
+ b, err := tfs.ReadFile("assets/" + name + ".scm")
+ if err != nil {
+ fatal("%v", err)
+ }
+ scm.Write(b)
}
- key := resolveKey(dark, name)
- if key == "" {
- key = resolveKey(light, name)
+ l := &language{lang: sitter.NewLanguage(g.lang())}
+ q, qerr := sitter.NewQuery(l.lang, scm.String())
+ if qerr != nil {
+ fatal("%s query: %v", strings.Join(g.scms, "+"), qerr)
}
- cls := strings.ReplaceAll(key, ".", "-")
- id := int16(0)
- for i, n := range h.classNames {
- if n == cls {
- id = int16(i)
- }
+ l.query = q
+ for _, name := range q.CaptureNames() {
+ l.captureCls = append(l.captureCls, h.classFor(dark, light, name))
}
- if id == 0 {
- h.classNames = append(h.classNames, cls)
- id = int16(len(h.classNames) - 1)
+ for _, ext := range g.exts {
+ h.byExt[ext] = l
}
- h.captureCls = append(h.captureCls, id)
}
return h
}
+// classFor interns the class id of a capture name, which doubles as a zed
+// theme syntax key. Captures both themes paint (nearly) as foreground get id 0
+// and no span at all — punctuation and friends are the bulk of all spans.
+func (h *highlighter) classFor(dark, light *Theme, name string) int16 {
+ if nearFg(dark, name) && nearFg(light, name) {
+ return 0
+ }
+ key := resolveKey(dark, name)
+ if key == "" {
+ key = resolveKey(light, name)
+ }
+ cls := strings.ReplaceAll(key, ".", "-")
+ for i, n := range h.classNames {
+ if n == cls {
+ return int16(i)
+ }
+ }
+ h.classNames = append(h.classNames, cls)
+ return int16(len(h.classNames) - 1)
+}
+
type paint struct {
start, end uint
pattern uint
cls int16
}
-// classify is safe for concurrent use: the shared query is immutable, and
-// parser and cursor are per-call.
+// classify is safe for concurrent use: the shared per-language query is
+// immutable, and parser and cursor are per-call. Extensions no grammar claims
+// come back unclassified rather than guessed at.
+//
+// Matches arrive already filtered by their #eq?/#match?/#any-of? predicates —
+// go-tree-sitter evaluates those in QueryMatches.Next — so every capture here
+// is one the query really meant.
func (h *highlighter) classify(path string, src []byte) []int16 {
classes := make([]int16, len(src))
- if !strings.HasSuffix(path, ".rs") {
+ l := h.byExt[strings.ToLower(filepath.Ext(path))]
+ if l == nil {
return classes
}
parser := sitter.NewParser()
defer parser.Close()
- parser.SetLanguage(h.lang)
+ parser.SetLanguage(l.lang)
tree := parser.Parse(src, nil)
defer tree.Close()
qc := sitter.NewQueryCursor()
defer qc.Close()
- matches := qc.Matches(h.query, tree.RootNode(), src)
+ matches := qc.Matches(l.query, tree.RootNode(), src)
var paints []paint
for m := matches.Next(); m != nil; m = matches.Next() {
for _, c := range m.Captures {
- if cls := h.captureCls[c.Index]; cls != 0 {
+ if cls := l.captureCls[c.Index]; cls != 0 {
paints = append(paints, paint{c.Node.StartByte(), c.Node.EndByte(), m.PatternIndex, cls})
}
}
}
- // later patterns in the query paint over earlier ones
+ // later patterns in the query paint over earlier ones; two captures of one
+ // pattern must not overlap, since this sort leaves their order arbitrary
sort.Slice(paints, func(i, j int) bool { return paints[i].pattern < paints[j].pattern })
for _, p := range paints {
for i := p.start; i < p.end && int(i) < len(classes); i++ {
diff --git a/vrsite/serve.go b/vrsite/serve.go
index c73630c..c6e1ef1 100644
--- a/vrsite/serve.go
+++ b/vrsite/serve.go
@@ -1,10 +1,24 @@
// serve.go — vrsite as a live web application.
//
-// The log is the only state: it is re-read whenever it grows, notes written
-// from the browser are appended to it, and the repository is only ever read
-// (see jj.go). Pages are rendered per request from the same templates the
-// static export uses; htmx swaps the parts that change on their own —
-// filtered note lists, the activity of agents currently reading, jj queries.
+// The server owns nothing. Three rules keep it that way, and app_test.go holds
+// each of them:
+//
+// - The log is the only thing written. Notes typed in the browser are
+// appended to it exactly as vr appends them; the repository is only ever
+// read (see jj.go), and no file of the app's own is ever created.
+// - Memory is a cache, never a source. The model, the per-change
+// projections, file lists and rendered pages are all derived from the log
+// and the repo, so a restart changes nothing anyone can see.
+// - What is cached is keyed by what it was derived from: the log's
+// generation, and the commit id — not the change id, which is a pointer
+// that moves whenever the change is edited.
+//
+// What a reader chose lives in the URL (which change, how wide the view) or in
+// their own browser (theme, focus, which folders are open). None of it is here.
+//
+// Pages are rendered per request from the same templates the static export
+// uses; htmx swaps the parts that change on their own — filtered note lists,
+// the activity of agents currently reading, jj queries.
package main
import (
@@ -33,6 +47,7 @@ type server struct {
tpl *template.Template
hl *highlighter
theme, light *Theme
+ tx *transcripts
mu sync.Mutex
site *Site
@@ -41,14 +56,19 @@ type server struct {
state logState
gen int
def string // resolved change the log points at
- trees map[string][]*File // change -> file list
- rendered map[string]*File // gen|change|path -> rendered file
+ models map[string]*Site // scope key -> the log projected onto it
+ trees map[string][]*File // scope key|change -> file list
+ rendered map[string]*File // gen|scope|change|path -> rendered file
}
+// scopeAll is the key of the unscoped model — the whole log, every change.
+const scopeAll = "*"
+
func newServer(logPath, repo, title, user string, theme, light *Theme) *server {
sv := &server{logPath: logPath, repo: repo, title: title, user: user,
session: "web:" + user, theme: theme, light: light,
- tpl: newTemplates(), trees: map[string][]*File{}, rendered: map[string]*File{}}
+ tpl: newTemplates(), models: map[string]*Site{}, trees: map[string][]*File{},
+ rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())}
sv.hl = newHighlighter(theme, light)
sv.reload(true)
return sv
@@ -63,8 +83,7 @@ func (sv *server) reload(force bool) {
}
if err != nil {
if sv.site == nil {
- sv.site = buildModel(sv.title, nil)
- sv.site.Theme, sv.site.Light = sv.theme, sv.light
+ sv.site = sv.dress(buildModel(sv.title, nil, ""))
}
return
}
@@ -84,61 +103,108 @@ func (sv *server) reload(force bool) {
sv.entries = append(sv.entries, parseLog(buf[:i+1])...)
}
sv.state = st
- sv.site = buildModel(sv.title, sv.entries)
- sv.site.Theme, sv.site.Light, sv.site.Repo = sv.theme, sv.light, sv.repo
+ sv.site = sv.dress(buildModel(sv.title, sv.entries, ""))
sv.gen++
sv.def = ""
+ sv.models = map[string]*Site{}
sv.trees = map[string][]*File{}
sv.rendered = map[string]*File{}
}
-// snap returns the current model, its generation, and the change the log
-// points at (falling back to the working copy when that change is unknown to
-// this repo).
+func (sv *server) dress(s *Site) *Site {
+ s.Theme, s.Light, s.Repo = sv.theme, sv.light, sv.repo
+ return s
+}
+
+// snap returns the whole-log model, its generation, and the change the log
+// points at: the most-logged change this repo actually has, since a log can
+// carry entries from several repos, falling back to the working copy.
func (sv *server) snap() (*Site, int, string) {
sv.mu.Lock()
defer sv.mu.Unlock()
sv.reload(false)
if sv.def == "" {
- def := sv.site.Change
- if def == "" {
- def = "@"
+ for i, c := range sv.site.Ranked {
+ if i == 5 {
+ break // a log with many foreign changes is not worth probing
+ }
+ if id, err := resolveChange(sv.repo, c); err == nil {
+ sv.def = id
+ break
+ }
}
- if id, err := resolveChange(sv.repo, def); err == nil {
- sv.def = id
- } else if id, err := resolveChange(sv.repo, "@"); err == nil {
- sv.def = id
- } else {
- sv.def = def
+ if sv.def == "" {
+ if id, err := resolveChange(sv.repo, "@"); err == nil {
+ sv.def = id
+ } else {
+ sv.def = sv.site.Change
+ }
}
- sv.site.All = sv.filesLocked(sv.site, sv.def)
}
return sv.site, sv.gen, sv.def
}
-func (sv *server) filesLocked(site *Site, change string) []*File {
- if f, ok := sv.trees[change]; ok {
+// modelFor returns the log projected onto one change, or the whole log when
+// change is "". Built once per generation, since the projection is pure.
+func (sv *server) modelFor(change string) *Site {
+ key := change
+ if key == "" {
+ key = scopeAll
+ }
+ sv.mu.Lock()
+ defer sv.mu.Unlock()
+ if s, ok := sv.models[key]; ok {
+ return s
+ }
+ s := sv.site
+ if change != "" {
+ s = sv.dress(buildModel(sv.title, sv.entries, change))
+ }
+ sv.models[key] = s
+ return s
+}
+
+func scopeKey(site *Site) string {
+ if site.Only == "" {
+ return scopeAll
+ }
+ return site.Only
+}
+
+func (sv *server) filesLocked(site *Site, commit, change string) []*File {
+ key := scopeKey(site) + "|" + commit
+ if f, ok := sv.trees[key]; ok {
return f
}
- paths, err := fileList(sv.repo, change)
+ paths, err := fileList(sv.repo, commit)
if err != nil {
return nil
}
f := site.filesAt(paths, change)
- sv.trees[change] = f
+ sv.trees[key] = f
return f
}
-func (sv *server) files(site *Site, change string) []*File {
+func (sv *server) files(site *Site, commit, change string) []*File {
sv.mu.Lock()
defer sv.mu.Unlock()
- return sv.filesLocked(site, change)
+ return sv.filesLocked(site, commit, change)
+}
+
+// forgetDefault drops the chosen change so the next request picks again —
+// the repo moved under us, which is the repo's business, not ours to remember.
+func (sv *server) forgetDefault() {
+ sv.mu.Lock()
+ sv.def = ""
+ sv.mu.Unlock()
}
-// fileAt renders one file at a change: model coverage and notes, source from
-// jj, highlighted. Results are cached per log generation.
-func (sv *server) fileAt(site *Site, gen int, change, path string) *File {
- key := fmt.Sprintf("%d|%s|%s", gen, change, path)
+// fileAt renders one file: model coverage and notes, source from jj,
+// highlighted. commit is the content it is read at and what the cache is keyed
+// on — change only names it on the page — so an edit under the same change id
+// is a different key, never a stale hit.
+func (sv *server) fileAt(site *Site, gen int, commit, change, path string) *File {
+ key := fmt.Sprintf("%d|%s|%s|%s", gen, scopeKey(site), commit, path)
sv.mu.Lock()
if f, ok := sv.rendered[key]; ok {
sv.mu.Unlock()
@@ -155,7 +221,7 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File {
if f.Change == "" {
f.Change = change
}
- src, err := fileShow(sv.repo, change, path)
+ src, err := fileShow(sv.repo, commit, path)
switch {
case err != nil:
f.Stub = "not present at this change"
@@ -178,20 +244,56 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File {
// ── request plumbing ──────────────────────────────────────────────────────
-// newPage resolves ?rev= and fills in everything the layout needs.
+// newPage answers the two questions every page starts from: which change am I
+// looking at, and am I seeing only what was recorded there. ?rev= picks the
+// change (default: the one the log points at); ?scope=all widens the view to
+// the whole log, and a change nothing was ever recorded at widens by itself,
+// so the site never looks mysteriously empty.
func (sv *server) newPage(r *http.Request, kind, title string) (*page, *Site, error) {
- site, _, def := sv.snap()
- p := &page{Site: site, Kind: kind, Title: title, Root: "/", Live: true,
- User: sv.user, ChangeID: def, Facets: site.Facets()}
- rev := strings.TrimSpace(r.URL.Query().Get("rev"))
- if rev != "" && rev != def {
- id, err := resolveChange(sv.repo, rev)
- if err != nil {
- return p, site, err
+ full, _, def := sv.snap()
+ q := r.URL.Query()
+ p := &page{Site: full, Kind: kind, Title: title, Root: "/", Live: true,
+ User: sv.user, ChangeID: def, Default: def, Path: r.URL.Path, Scope: "change"}
+ rev := strings.TrimSpace(q.Get("rev"))
+ target := rev
+ if target == "" {
+ target = def
+ }
+ row, err := changeBrief(sv.repo, target)
+ if err != nil {
+ if rev == "" {
+ sv.forgetDefault() // the log's change left the repo; pick again
}
- p.Rev, p.ChangeID = rev, id
+ p.Facets = full.Facets()
+ return p, full, err
+ }
+ p.ChangeID, p.Commit, p.ChangeInfo = row.ID, row.Commit, &row
+ if rev != "" && row.ID != def {
+ p.Rev = rev
}
- return p, site, nil
+ if q.Get("scope") == "all" {
+ p.Scope = "all"
+ }
+ return p, sv.rescope(p), nil
+}
+
+// rescope picks the model for the change the page settled on. Handlers that
+// discover their change from the path (a change page) call it again.
+func (sv *server) rescope(p *page) *Site {
+ full, _, _ := sv.snap()
+ site := full
+ p.ScopeEmpty = false
+ if p.Scope == "change" {
+ if full.Votes[p.ChangeID] > 0 {
+ site = sv.modelFor(p.ChangeID)
+ } else {
+ p.ScopeEmpty = true // asked for this change, nothing was recorded here
+ }
+ }
+ p.Site, p.Facets = site, site.Facets()
+ p.Here = full.NotesAt[p.ChangeID]
+ p.Elsewhere = full.NotesTotal - p.Here
+ return site
}
func (sv *server) render(w http.ResponseWriter, p *page) {
@@ -233,6 +335,7 @@ func (sv *server) handler() http.Handler {
mux.HandleFunc("GET /notes/list", sv.notesList)
mux.HandleFunc("POST /notes", sv.addNote)
mux.HandleFunc("GET /activity", sv.activity)
+ mux.HandleFunc("GET /session/{id}", sv.sessionPage)
mux.HandleFunc("GET /jj", sv.jjPage)
mux.HandleFunc("GET /jj/log", sv.jjLog)
mux.HandleFunc("GET /jj/op", sv.jjOp)
@@ -254,6 +357,7 @@ func (sv *server) index(w http.ResponseWriter, r *http.Request) {
if len(p.Notes) > 24 {
p.Notes = p.Notes[:24]
}
+ p.Files = sv.files(site, p.Commit, p.ChangeID)
sv.render(w, p)
}
@@ -264,7 +368,7 @@ func (sv *server) filesPage(w http.ResponseWriter, r *http.Request) {
sv.render(w, p)
return
}
- p.Tree = template.HTML(renderTree(sv.files(site, p.ChangeID), p.codeLink()))
+ p.Tree = template.HTML(renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink()))
sv.render(w, p)
}
@@ -275,7 +379,7 @@ func (sv *server) treeFrag(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- fmt.Fprint(w, renderTree(sv.files(site, p.ChangeID), p.codeLink()))
+ fmt.Fprint(w, renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink()))
}
func (sv *server) codePage(w http.ResponseWriter, r *http.Request) {
@@ -288,7 +392,7 @@ func (sv *server) codePage(w http.ResponseWriter, r *http.Request) {
}
_, gen, _ := sv.snap()
p.Current = path
- p.File = sv.fileAt(site, gen, p.ChangeID, path)
+ p.File = sv.fileAt(site, gen, p.Commit, p.ChangeID, path)
sv.render(w, p)
}
@@ -338,15 +442,25 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "a note needs text", http.StatusBadRequest)
return
}
+ // a note belongs to the change its writer was reading, which is the one
+ // the form carries — not the working copy, which may have moved on since
+ _, _, def := sv.snap()
rev := strings.TrimSpace(r.FormValue("rev"))
- if rev == "" {
- rev = "@"
+ target := strings.TrimSpace(r.FormValue("change"))
+ for _, fallback := range []string{rev, def, "@"} {
+ if target != "" {
+ break
+ }
+ target = fallback
}
- change, err := resolveChange(sv.repo, rev)
+ change, err := resolveChange(sv.repo, target)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
+ if rev == "" {
+ rev = target
+ }
start := atoiDefault(r.FormValue("start"), 0)
end := atoiDefault(r.FormValue("end"), 0)
if end < start {
@@ -371,6 +485,12 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "write log: "+err.Error(), http.StatusInternalServerError)
return
}
+ // answer in the same view the writer is looking at, or the note count
+ // coming back would be the whole log's while their page shows one change
+ scope := r.FormValue("scope")
+ if scope != "all" {
+ site = sv.modelFor(change)
+ }
if !htmxReq(r) {
back := r.FormValue("back")
@@ -406,6 +526,24 @@ func (sv *server) activity(w http.ResponseWriter, r *http.Request) {
sv.frag(w, "activity", p)
}
+// sessionPage shows one session's conversation, with ?note=N anchoring the
+// moment a note was written. It renders from the log alone when the harness
+// transcript cannot be found — the reading timeline is still worth having.
+func (sv *server) sessionPage(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := safeSession(id); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ p, site, err := sv.newPage(r, "session", "session "+short(id)+" — "+sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ }
+ q := r.URL.Query()
+ p.Conv = sv.conversation(site, id, atoiDefault(q.Get("note"), 0), q.Get("all") != "")
+ sv.render(w, p)
+}
+
// ── jj ────────────────────────────────────────────────────────────────────
const defaultLimit = 50
@@ -479,8 +617,11 @@ func (sv *server) changePage(w http.ResponseWriter, r *http.Request) {
return
}
p.Change = c
- p.Rev, p.ChangeID = c.Row.ID, c.Row.ID
+ p.Rev, p.ChangeID, p.Commit = c.Row.ID, c.Row.ID, c.Row.Commit
+ p.ChangeInfo = &c.Row
p.Title = c.Row.Short + " — " + sv.title
+ // the page named a change the query string did not, so the view follows it
+ site = sv.rescope(p)
p.Notes = site.FilterNotes(NoteFilter{Change: c.Row.ID})
sv.render(w, p)
}
@@ -510,12 +651,14 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) {
w.Write(b)
}
-func serve(addr, logPath, repo, title, user string, theme, light *Theme) {
+func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme) {
sv := newServer(logPath, repo, title, user, theme, light)
+ sv.tx = newTranscripts(roots)
site, _, def := sv.snap()
fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n",
addr, len(site.Notes), len(site.Agents), repo, short(def))
fmt.Printf("vrsite: comments are appended to %s; the repo is never written to\n", logPath)
+ fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", "))
if err := http.ListenAndServe(addr, sv.handler()); err != nil {
fatal("%v", err)
}
diff --git a/vrsite/static.go b/vrsite/static.go
index e4970c6..7d31b1d 100644
--- a/vrsite/static.go
+++ b/vrsite/static.go
@@ -33,20 +33,32 @@ func run(dir string, name string, args ...string) []byte {
func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) {
start := time.Now()
- site := buildModel(title, readLog(logPath))
- site.Theme, site.Light, site.Repo = theme, light, repoAbs
- if site.Change == "" {
+ entries := readLog(logPath)
+ ranked := buildModel(title, entries, "").Ranked
+ if len(ranked) == 0 {
fatal("log has no jj change ids")
}
-
- // every file in the repo at the site change; content comes from one
- // git archive of the commit instead of a jj spawn per file
- list, err := jjRun(repoAbs, "file", "list", "-r", site.Change)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- fatal("change %s is not in %s — logs are per repository; point -repo at the one the trace was recorded in", short(site.Change), repoAbs)
+ // the most-logged change this repo actually has: a log can span repos, and
+ // the busiest change in it may belong to another one
+ var change string
+ var list []byte
+ for _, c := range ranked {
+ out, err := jjRun(repoAbs, "file", "list", "-r", c)
+ if err == nil {
+ change, list = c, out
+ break
+ }
}
- site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), site.Change)
+ if change == "" {
+ fatal("none of the log's %d changes are in %s — logs are per repository; point -repo at the one the trace was recorded in",
+ len(ranked), repoAbs)
+ }
+ // an export is a picture of one change, so it holds only what was
+ // recorded there; the notes page says how much of the log that leaves out
+ site := buildModel(title, entries, change)
+ site.Change = change
+ site.Theme, site.Light, site.Repo = theme, light, repoAbs
+ site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), change)
commit := strings.TrimSpace(string(mustJJ(repoAbs, "log", "-r", site.Change, "--no-graph", "-T", "commit_id")))
tmp, err := os.MkdirTemp("", "vrsite-")
@@ -140,8 +152,22 @@ func writeSite(site *Site, out, srcDir string) {
}
facets := site.Facets()
+ var info *Row
+ if rows, err := LogRows(site.Repo, site.Change, "", 1); err == nil {
+ for _, row := range rows {
+ if !row.Blank() {
+ r := row
+ info = &r
+ break
+ }
+ }
+ }
render := func(path string, p *page) {
- p.Site, p.Facets, p.ChangeID = site, facets, site.Change
+ p.Site, p.Facets = site, facets
+ p.ChangeID, p.Default, p.ChangeInfo = site.Change, site.Change, info
+ p.Scope, p.Here = "change", site.NotesAt[site.Change]
+ p.Elsewhere = site.NotesTotal - p.Here
+ p.Files = site.All
f := mustCreate(path)
if err := tpl.Execute(f, p); err != nil {
fatal("render %s: %v", path, err)
diff --git a/vrsite/templates/frag-activity.tmpl b/vrsite/templates/frag-activity.tmpl
index 8c15de7..071c556 100644
--- a/vrsite/templates/frag-activity.tmpl
+++ b/vrsite/templates/frag-activity.tmpl
@@ -4,7 +4,8 @@
<div class="actlist">
{{range .Sessions}}
<div class="act{{if .Live}} on{{end}}" title="{{.Agent}} · {{.Model}} · {{.Dir}}">
- <span class="chip am{{.Bit}}">{{.Short}}</span>
+ {{if and $.Live .ID}}<a class="chip am{{.Bit}}" href="{{$.Href "session" .ID}}">{{.Short}}</a>
+ {{else}}<span class="chip am{{.Bit}}">{{.Short}}</span>{{end}}
<span class="what">{{.LastOp}}</span>
<span class="when">{{.Ago}}</span>
</div>
@@ -19,7 +20,8 @@
<tr class="{{if .Live}}on{{end}}">
<td><span class="chip am{{.Bit}}">{{.Short}}</span>{{if .Live}}<span class="pulse" title="active">●</span>{{end}}</td>
<td>{{.Model}}{{if .Reason}}<span class="meta">/{{.Reason}}</span>{{end}}</td>
- <td><code title="{{.ID}}">{{short .ID}}</code></td>
+ <td>{{if and $.Live .ID}}<a href="{{$.Href "session" .ID}}" title="{{.ID}}"><code>{{short .ID}}</code></a>
+ {{else}}<code title="{{.ID}}">{{short .ID}}</code>{{end}}</td>
<td class="what">{{.LastOp}}</td>
<td>{{.Ago}}</td>
<td class="meta">{{.Reads}}/{{.Greps}}/{{.Notes}}</td>
diff --git a/vrsite/templates/frag-change.tmpl b/vrsite/templates/frag-change.tmpl
new file mode 100644
index 0000000..b33d746
--- /dev/null
+++ b/vrsite/templates/frag-change.tmpl
@@ -0,0 +1,41 @@
+{{/* The change everything on screen is read at, and how wide the view is.
+ Every page carries it, because every read, note and file listing below
+ is relative to it. */}}
+{{define "changebar"}}
+<section class="changebar{{if .Rev}} away{{end}}" aria-label="current change">
+ <div class="cid">
+ <span class="lbl">change</span>
+ {{if .Live}}<a href="{{.Href "change" .ChangeID}}" title="{{.ChangeID}}"><code>{{short .ChangeID}}</code></a>
+ {{else}}<code title="{{.ChangeID}}">{{short .ChangeID}}</code>{{end}}
+ {{with .ChangeInfo}}{{if .Flags}}<span class="flags">{{.Flags}}</span>{{end}}{{end}}
+ </div>
+ {{with .ChangeInfo}}
+ <p class="desc" title="{{.Desc}}">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</p>
+ {{end}}
+
+ <p class="counts">
+ {{if eq .Scope "all"}}
+ <b>{{.Site.NotesTotal}}</b> notes across every change{{if .Here}} · {{.Here}} here{{end}}
+ {{else if .ScopeEmpty}}
+ nothing recorded at this change — showing all <b>{{.Site.NotesTotal}}</b>
+ {{else}}
+ <b>{{.Here}}</b> notes here{{if .Elsewhere}} · <span class="away">{{.Elsewhere}} elsewhere</span>{{end}}
+ {{end}}
+ </p>
+
+ {{if .Live}}
+ <div class="scope" role="group" aria-label="what this view covers">
+ <a href="{{.ScopeHref "change"}}"{{if eq .Scope "change"}} class="on" aria-current="true"{{end}}
+ title="only what was recorded at this change">this change</a>
+ <a href="{{.ScopeHref "all"}}"{{if eq .Scope "all"}} class="on" aria-current="true"{{end}}
+ title="every change in the log">all</a>
+ </div>
+ <form class="revjump" method="get" action="{{.JumpPath}}">
+ <input type="search" name="rev" value="{{.Rev}}" placeholder="@, id, revset…"
+ aria-label="read at another change" autocomplete="off" spellcheck="false">
+ {{if eq .Scope "all"}}<input type="hidden" name="scope" value="all">{{end}}
+ </form>
+ {{if .Rev}}<p class="back"><a href="{{.BackHref}}">← back to <code>{{short .Default}}</code>, where the log points</a></p>{{end}}
+ {{end}}
+</section>
+{{end}}
diff --git a/vrsite/templates/frag-note.tmpl b/vrsite/templates/frag-note.tmpl
index 56f7a8f..0c3182c 100644
--- a/vrsite/templates/frag-note.tmpl
+++ b/vrsite/templates/frag-note.tmpl
@@ -1,7 +1,8 @@
-{{define "note"}}{{$n := .N}}{{$p := .P}}
-<article class="note am{{$n.Agent.Bit}}" id="note-{{$n.ID}}"
+{{define "note"}}{{$n := .N}}{{$p := .P}}{{$away := and $n.Change $p.ChangeID (ne $n.Change $p.ChangeID)}}
+<article class="note am{{$n.Agent.Bit}}{{if $away}} away{{end}}" id="note-{{$n.ID}}"
data-agent="{{$n.Agent.Name}}" data-model="{{$n.Model}}" data-session="{{$n.Session}}"
- data-kind="{{$n.Kind}}" data-file="{{$n.File}}" data-date="{{if not $n.At.IsZero}}{{$n.At.Format "2006-01-02"}}{{end}}">
+ data-kind="{{$n.Kind}}" data-file="{{$n.File}}" data-date="{{if not $n.At.IsZero}}{{$n.At.Format "2006-01-02"}}{{end}}"
+ data-start="{{$n.Start}}" data-end="{{$n.End}}">
<header>
{{if $n.File}}<a href="{{$p.NoteHref $n}}" title="{{$n.File}}">{{base $n.File}}{{lines $n}}</a>{{end}}
{{if $n.Kind}}<em>{{$n.Kind}}</em>{{end}}
@@ -12,7 +13,12 @@
{{if not $n.At.IsZero}}<time datetime="{{$n.At.Format "2006-01-02T15:04:05Z07:00"}}" title="{{$n.Ago}}">{{$n.When}}</time>{{end}}
{{if $n.Model}}<span class="model">{{$n.Model}}{{if $n.Reason}}/{{$n.Reason}}{{end}}</span>{{end}}
{{if $n.Session}}<code class="sess" title="{{$n.Session}}">{{short $n.Session}}</code>{{end}}
- {{if $n.Change}}{{if $p.Live}}<a href="{{$p.Href "change" $n.Change}}"><code>{{short $n.Change}}</code></a>{{else}}<code>{{short $n.Change}}</code>{{end}}{{end}}
+ {{with $p.ConvHref $n}}<a class="conv" href="{{.}}" title="open the conversation this note was written in">conversation</a>{{end}}
+ {{if $n.Change}}
+ {{if $p.Live}}<a class="atchg{{if $away}} away{{end}}" href="{{$p.Href "change" $n.Change}}"
+ title="{{if $away}}written at another change{{else}}written at this change{{end}}"><code>{{short $n.Change}}</code></a>
+ {{else}}<code class="atchg{{if $away}} away{{end}}">{{short $n.Change}}</code>{{end}}
+ {{end}}
</footer>
</article>
{{end}}
@@ -30,6 +36,8 @@
hx-on::after-request="if(event.detail.successful){this.reset();{{if eq .Kind "notes"}}htmx.trigger('#notefilters','change');{{end}}}"
method="post" action="{{.Href "notes"}}">
<input type="hidden" name="rev" value="{{.Rev}}">
+ <input type="hidden" name="change" value="{{.ChangeID}}">
+ <input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="back" value="{{if .File}}{{.Href "code" .File.Path}}{{else}}{{.Href "notes"}}{{end}}">
<div class="row">
{{if .File}}
@@ -44,7 +52,7 @@
</div>
<textarea name="text" rows="3" placeholder="comment… (⌘/ctrl+enter to post)" required></textarea>
<div class="row end">
- <span class="meta">as <b>{{.User}}</b> → the log</span>
+ <span class="meta">as <b>{{.User}}</b> at <code title="{{.ChangeID}}">{{short .ChangeID}}</code> → the log</span>
<button type="submit">add note</button>
</div>
</form>
diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl
index c3fe250..9bc9ddd 100644
--- a/vrsite/templates/layout.tmpl
+++ b/vrsite/templates/layout.tmpl
@@ -23,9 +23,7 @@
<button type="button" class="mode" data-mode="notes" title="notes and comments (t)">notes</button>
</div>
</header>
- {{if and .Live .Rev}}
- <p class="meta atrev">browsing <a href="{{.Href "change" .ChangeID}}"><code>{{short .ChangeID}}</code></a> · <a href="/">back to @</a></p>
- {{end}}
+ {{template "changebar" .}}
{{if .Live}}
<div id="activity" class="activity" hx-get="/activity?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div>
{{end}}
@@ -52,6 +50,7 @@
{{else if eq .Kind "files"}}{{template "page-files" .}}
{{else if eq .Kind "jj"}}{{template "page-jj" .}}
{{else if eq .Kind "change"}}{{template "page-change" .}}
+{{else if eq .Kind "session"}}{{template "page-session" .}}
{{else}}{{template "page-index" .}}{{end}}
</main>
diff --git a/vrsite/templates/page-files.tmpl b/vrsite/templates/page-files.tmpl
index 53757ea..3f158fe 100644
--- a/vrsite/templates/page-files.tmpl
+++ b/vrsite/templates/page-files.tmpl
@@ -2,8 +2,8 @@
<header class="filehead">
<h1>Files</h1>
<p class="meta">
- everything at change <code>{{short .ChangeID}}</code> ·
- <span class="trace-only">traced files carry a coverage bar</span>
+ everything at change <code title="{{.ChangeID}}">{{short .ChangeID}}</code> ·
+ <span class="trace-only">traced files carry a coverage bar{{if eq .Scope "change"}}, from reads at this change{{end}}</span>
<span class="notes-only">◆ marks files with notes</span>
</p>
</header>
diff --git a/vrsite/templates/page-index.tmpl b/vrsite/templates/page-index.tmpl
index 2c115cc..bd377ac 100644
--- a/vrsite/templates/page-index.tmpl
+++ b/vrsite/templates/page-index.tmpl
@@ -2,8 +2,10 @@
<header class="filehead">
<h1>{{.Site.Title}}</h1>
<p class="meta">
- {{len .Site.All}} files, {{len .Site.Order}} traced · {{len .Site.Notes}} notes ·
- change <code>{{short .Site.Change}}</code> · theme {{.Site.Theme.Name}}
+ {{len .Files}} files at <code title="{{.ChangeID}}">{{short .ChangeID}}</code> ·
+ {{len .Site.Order}} traced, {{len .Site.Notes}} notes
+ {{if or (eq .Scope "all") .ScopeEmpty}}<span class="away">across every change</span>{{else}}here{{end}} ·
+ theme {{.Site.Theme.Name}}
</p>
</header>
@@ -23,7 +25,9 @@
<tbody>
{{range .Site.Agents}}
<tr><td><span class="chip am{{.Bit}}">{{.Short}}</span></td>
- <td>{{.Model}}</td><td>{{.Reason}}</td><td><code>{{short .Session}}</code></td>
+ <td>{{.Model}}</td><td>{{.Reason}}</td>
+ <td>{{if and $.Live .Session}}<a href="{{$.Href "session" .Session}}" title="{{.Session}}"><code>{{short .Session}}</code></a>
+ {{else}}<code title="{{.Session}}">{{short .Session}}</code>{{end}}</td>
<td>{{.Reads}}</td><td>{{.Greps}}</td><td>{{.Notes}}</td></tr>
{{end}}
</tbody>
diff --git a/vrsite/templates/page-notes.tmpl b/vrsite/templates/page-notes.tmpl
index 974dc64..242c137 100644
--- a/vrsite/templates/page-notes.tmpl
+++ b/vrsite/templates/page-notes.tmpl
@@ -7,7 +7,16 @@
<header class="filehead">
<h1>Notes</h1>
<p class="meta">
- {{len .Site.Notes}} in the log{{if .Live}} · anything you add here is appended to the log — the repo is never touched{{end}}
+ {{if eq .Scope "all"}}
+ all {{.Site.NotesTotal}} in the log, every change
+ {{else if .ScopeEmpty}}
+ all {{.Site.NotesTotal}} in the log — nothing was recorded at <code>{{short .ChangeID}}</code>
+ {{else}}
+ {{len .Site.Notes}} written at <code title="{{.ChangeID}}">{{short .ChangeID}}</code>{{if .Elsewhere}} ·
+ {{if .Live}}<a href="{{.ScopeHref "all"}}">{{.Elsewhere}} more at other changes</a>
+ {{else}}{{.Elsewhere}} more at other changes are not in this export{{end}}{{end}}
+ {{end}}
+ {{if .Live}} · anything you add here is appended to the log at this change — the repo is never touched{{end}}
</p>
</header>
diff --git a/vrsite/templates/page-session.tmpl b/vrsite/templates/page-session.tmpl
new file mode 100644
index 0000000..9a35e8d
--- /dev/null
+++ b/vrsite/templates/page-session.tmpl
@@ -0,0 +1,70 @@
+{{/* One session's conversation, with its vr log woven in by time. */}}
+{{define "page-session"}}
+{{$c := .Conv}}
+<header class="filehead">
+ <h1>
+ {{if $c.Agent}}<span class="chip am{{$c.Bit}}">{{$c.Agent}}</span> {{end}}
+ <code title="{{$c.Session}}">{{short $c.Session}}</code>
+ </h1>
+ <p class="meta">
+ {{if $c.Model}}{{$c.Model}} · {{end}}{{$c.Reads}} reads · {{$c.Greps}} greps · {{$c.Notes}} notes
+ {{if $c.Dir}} · <span title="{{$c.Dir}}">{{base $c.Dir}}</span>{{end}}
+ {{if $c.Harness}} · {{$c.Harness}}{{end}}
+ </p>
+ {{if $c.Path}}
+ <p class="meta"><code>{{$c.Path}}</code></p>
+ {{else}}
+ <p class="meta hint">no transcript found for this session — searched
+ {{if $c.Roots}}{{range $i, $r := $c.Roots}}{{if $i}}, {{end}}<code>{{$r}}</code>{{end}}{{else}}nowhere: <code>-transcripts</code> is empty{{end}} ·
+ showing the vr log alone</p>
+ {{end}}
+ {{if $c.Err}}<p class="err">{{$c.Err}}</p>{{end}}
+</header>
+
+<section class="convpage">
+ {{if $c.Windowed}}
+ <p class="meta">
+ moments {{$c.From}}–{{$c.To}} of {{$c.Total}}{{if $c.Anchor}}, around the note{{end}} ·
+ {{if $c.Incomplete}}the rest is beyond what one page renders{{else}}<a href="{{$c.AllHref}}">show the whole conversation</a>{{end}}
+ </p>
+ {{end}}
+
+ <div class="conv" data-anchor="{{$c.Anchor}}">
+ {{range $c.Turns}}
+ {{if .Note}}
+ <div class="turn t-note{{if .Here}} here{{end}}">
+ <span class="role">note</span><time>{{.Clock}}</time>
+ {{if .Note.File}}
+ <div class="notewrap" data-href="{{$.NoteHref .Note}}" tabindex="0" role="link"
+ title="open this note on the code it is about">{{template "note" (dict "N" .Note "P" $)}}</div>
+ {{else}}
+ <div class="notewrap">{{template "note" (dict "N" .Note "P" $)}}</div>
+ {{end}}
+ </div>
+ {{else if eq .Role "vr"}}
+ <div class="turn t-vr">
+ <span class="role">vr</span><time>{{.Clock}}</time>
+ {{if .File}}<a class="what" href="{{$.Href "code" .File}}">{{.Text}}</a>
+ {{else}}<span class="what">{{.Text}}</span>{{end}}
+ </div>
+ {{else if eq .Role "tool"}}
+ <div class="turn t-tool">
+ <span class="role">{{.Tool}}</span><time>{{.Clock}}</time>
+ <code class="what">{{.Detail}}</code>
+ </div>
+ {{else if eq .Role "result"}}
+ <div class="turn t-result{{if eq .Kind "error"}} bad{{end}}">
+ <span class="role">→{{if .Tool}} {{.Tool}}{{end}}</span><time></time>
+ <code class="what">{{.Detail}}</code>
+ </div>
+ {{else}}
+ <div class="turn t-say t-{{.Role}}">
+ <span class="role">{{.Role}}</span><time>{{.Clock}}</time>
+ <p>{{.Text}}</p>
+ </div>
+ {{end}}
+ {{end}}
+ {{if not $c.Turns}}<p class="meta hint">nothing recorded for this session</p>{{end}}
+ </div>
+</section>
+{{end}}
diff --git a/vrsite/transcript.go b/vrsite/transcript.go
new file mode 100644
index 0000000..713fe36
--- /dev/null
+++ b/vrsite/transcript.go
@@ -0,0 +1,602 @@
+// transcript.go — the conversation a session's reading happened inside.
+//
+// The vr log says a session read a file at 10:04; the harness that ran that
+// session kept the whole conversation on disk, in its own format. Here we find
+// that file by session id and flatten both known formats into Turns, so a note
+// can be opened at the moment it was written.
+//
+// Two rules keep this cheap and safe. Tool calls and their output are reduced
+// to a line each — transcripts reach megabytes and none of that belongs in a
+// page — and nothing outside a configured root is ever opened, with session
+// ids checked before they touch the filesystem.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ maxTranscript = 32 << 20 // refuse to parse more than this in one file
+ transcriptCacheMax = 8 // parsed transcripts are large; keep few
+ briefLine = 160 // one line of a tool call or its output
+ maxText = 6000 // one message
+ convWindow = 80 // turns rendered around the anchored moment
+ convMax = 1500 // turns rendered by "show all"
+)
+
+// A Turn is one moment in a session: something said, a tool called or answered,
+// or — woven in from the vr log — a read, a grep, a note.
+type Turn struct {
+ Role string // user | assistant | thinking | tool | result | vr
+ Kind string // qualifies Role: the vr op, or "error" on a failed tool
+ When time.Time
+ Text string // what was said
+ Tool string // tool name, on tool and result turns
+ Detail string // one compact line: the call's arguments, or its output
+ File string // vr turns: the file the op touched
+ Note *Note // vr note turns: the note itself, rendered as a card
+ Here bool // the anchored moment
+}
+
+func (t Turn) Clock() string {
+ if t.When.IsZero() {
+ return ""
+ }
+ return t.When.Local().Format("15:04:05")
+}
+
+// A Transcript is one harness conversation, already normalized.
+type Transcript struct {
+ Path string
+ Harness string // claude-code | codex
+ Turns []Turn
+ Err string
+}
+
+// transcripts finds and parses harness transcripts, remembering what it has
+// already parsed. Roots are the only places it will look.
+type transcripts struct {
+ roots []string
+
+ mu sync.Mutex
+ cache map[string]*Transcript // path|mtime|size -> parsed
+}
+
+func newTranscripts(roots []string) *transcripts {
+ tx := &transcripts{cache: map[string]*Transcript{}}
+ for _, r := range roots {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ if abs, err := filepath.Abs(r); err == nil {
+ r = abs
+ }
+ tx.roots = append(tx.roots, filepath.Clean(r))
+ }
+ return tx
+}
+
+// defaultTranscriptRoots are where the two harnesses we know about keep their
+// conversations.
+func defaultTranscriptRoots() []string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil
+ }
+ return []string{filepath.Join(home, ".claude", "projects"), filepath.Join(home, ".codex", "sessions")}
+}
+
+// safeSession refuses ids that could name something we did not mean to open:
+// the id goes into a filesystem path, so it must be one plain component.
+func safeSession(id string) error {
+ if id == "" {
+ return fmt.Errorf("no session id")
+ }
+ if len(id) > 128 {
+ return fmt.Errorf("session id is too long")
+ }
+ for _, r := range id {
+ ok := r == '-' || r == '_' || r == ':' || r == '.' ||
+ '0' <= r && r <= '9' || 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z'
+ if !ok {
+ return fmt.Errorf("session id contains %q", r)
+ }
+ }
+ if strings.Contains(id, "..") {
+ return fmt.Errorf("session id contains %q", "..")
+ }
+ return nil
+}
+
+// mangleDir is how Claude Code names a project directory after its cwd:
+// /home/u/0x4200.cafe -> -home-u-0x4200-cafe.
+func mangleDir(dir string) string {
+ return strings.NewReplacer("/", "-", ".", "-").Replace(dir)
+}
+
+// under states the rule the globs below already obey: a transcript we open
+// lies inside a configured root. It is checked anyway, so the rule has one
+// place to fail rather than living in the shape of two patterns.
+func under(root, path string) bool {
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return false
+ }
+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+// find locates a session's transcript. Claude Code writes
+// <root>/<mangled cwd>/<session>.jsonl; codex writes
+// <root>/YYYY/MM/DD/rollout-<time>-<thread>.jsonl and vr's session id is that
+// thread id, so it is matched on the suffix. Both patterns are tried under
+// every root — a root knows its own layout, we do not have to.
+func (tx *transcripts) find(session, dir string) string {
+ if safeSession(session) != nil {
+ return ""
+ }
+ for _, root := range tx.roots {
+ pats := []string{
+ filepath.Join(root, "*", session+".jsonl"),
+ filepath.Join(root, "*", "*", "*", "rollout-*-"+session+".jsonl"),
+ }
+ // the cwd we logged names the directory directly, when it still exists
+ if dir != "" {
+ pats = append([]string{filepath.Join(root, mangleDir(dir), session+".jsonl")}, pats...)
+ }
+ for _, pat := range pats {
+ hits, _ := filepath.Glob(pat)
+ for _, hit := range hits {
+ if under(root, hit) {
+ return hit
+ }
+ }
+ }
+ }
+ return ""
+}
+
+// load returns the parsed transcript for a session, or nil when there is none.
+// Parses are cached by path, mtime and size, so a 5 MB file is read once.
+func (tx *transcripts) load(session, dir string) *Transcript {
+ path := tx.find(session, dir)
+ if path == "" {
+ return nil
+ }
+ st, err := os.Stat(path)
+ if err != nil {
+ return nil
+ }
+ key := fmt.Sprintf("%s|%d|%d", path, st.ModTime().UnixNano(), st.Size())
+
+ tx.mu.Lock()
+ t, ok := tx.cache[key]
+ tx.mu.Unlock()
+ if ok {
+ return t
+ }
+
+ t = readTranscript(path, st.Size())
+ tx.mu.Lock()
+ if len(tx.cache) >= transcriptCacheMax {
+ tx.cache = map[string]*Transcript{}
+ }
+ tx.cache[key] = t
+ tx.mu.Unlock()
+ return t
+}
+
+// ── parsing ───────────────────────────────────────────────────────────────
+
+// One line of either format. Codex wraps every record in "payload"; Claude
+// Code puts the model turn in "message". Sniffing per line keeps the two
+// parsers independent of any file header.
+type rawRecord struct {
+ Type string `json:"type"`
+ Timestamp string `json:"timestamp"`
+ Payload json.RawMessage `json:"payload"`
+ Message json.RawMessage `json:"message"`
+}
+
+type parser struct {
+ t *Transcript
+ tools map[string]string // tool call id -> name, so results keep their name
+ last time.Time // records without a stamp inherit the previous one
+}
+
+func readTranscript(path string, size int64) *Transcript {
+ t := &Transcript{Path: path}
+ if size > maxTranscript {
+ t.Err = fmt.Sprintf("transcript is %d MB — too large to parse", size>>20)
+ return t
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Err = err.Error()
+ return t
+ }
+ p := &parser{t: t, tools: map[string]string{}}
+ for _, line := range strings.Split(string(b), "\n") {
+ if line == "" {
+ continue
+ }
+ var rec rawRecord
+ if json.Unmarshal([]byte(line), &rec) != nil {
+ continue
+ }
+ at := parseTime(rec.Timestamp)
+ if at.IsZero() {
+ at = p.last
+ } else {
+ p.last = at
+ }
+ switch {
+ case len(rec.Payload) > 0:
+ t.Harness = "codex"
+ p.codex(rec, at)
+ case len(rec.Message) > 0:
+ t.Harness = "claude-code"
+ p.claude(rec, at)
+ }
+ }
+ return t
+}
+
+// add drops turns that would render as nothing — empty thinking blocks,
+// tool results whose output was pure whitespace.
+func (p *parser) add(t Turn) {
+ if t.Text == "" && t.Detail == "" && t.Tool == "" {
+ return
+ }
+ t.Text = clip(t.Text, maxText)
+ p.t.Turns = append(p.t.Turns, t)
+}
+
+// ── claude code ───────────────────────────────────────────────────────────
+
+type claudeMsg struct {
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"` // a string, or blocks
+}
+
+type claudeBlock struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Thinking string `json:"thinking"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Input json.RawMessage `json:"input"`
+ ToolUseID string `json:"tool_use_id"`
+ Content json.RawMessage `json:"content"` // tool_result: a string, or blocks
+ IsError bool `json:"is_error"`
+}
+
+// claude turns records of type user and assistant into Turns; the rest of the
+// file (modes, titles, file history, attachments) is harness bookkeeping.
+func (p *parser) claude(rec rawRecord, at time.Time) {
+ if rec.Type != "user" && rec.Type != "assistant" {
+ return
+ }
+ var m claudeMsg
+ if json.Unmarshal(rec.Message, &m) != nil {
+ return
+ }
+ var text string
+ if json.Unmarshal(m.Content, &text) == nil { // plain prompts arrive as a string
+ p.add(Turn{Role: rec.Type, When: at, Text: text})
+ return
+ }
+ var blocks []claudeBlock
+ if json.Unmarshal(m.Content, &blocks) != nil {
+ return
+ }
+ for _, b := range blocks {
+ switch b.Type {
+ case "text":
+ p.add(Turn{Role: rec.Type, When: at, Text: b.Text})
+ case "thinking":
+ p.add(Turn{Role: "thinking", When: at, Text: b.Thinking})
+ case "tool_use":
+ p.tools[b.ID] = b.Name
+ p.add(Turn{Role: "tool", When: at, Tool: b.Name, Detail: callDetail(b.Input)})
+ case "tool_result":
+ t := Turn{Role: "result", When: at, Tool: p.tools[b.ToolUseID], Detail: resultDetail(b.Content)}
+ if b.IsError {
+ t.Kind = "error"
+ }
+ p.add(t)
+ }
+ }
+}
+
+// ── codex ─────────────────────────────────────────────────────────────────
+
+type codexPayload struct {
+ Type string `json:"type"`
+ Role string `json:"role"`
+ Name string `json:"name"`
+ CallID string `json:"call_id"`
+ Arguments json.RawMessage `json:"arguments"` // a JSON string holding JSON
+ Input json.RawMessage `json:"input"`
+ Output json.RawMessage `json:"output"`
+ Content []codexText `json:"content"`
+ Summary []codexText `json:"summary"`
+}
+
+type codexText struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+// codex reads response_items — the model's own history. The event_msg stream
+// beside it is the TUI's view of the same turns, so taking both would double
+// everything.
+func (p *parser) codex(rec rawRecord, at time.Time) {
+ if rec.Type != "response_item" {
+ return
+ }
+ var pl codexPayload
+ if json.Unmarshal(rec.Payload, &pl) != nil {
+ return
+ }
+ switch pl.Type {
+ case "message":
+ // developer messages are the harness's own instructions, and the first
+ // user messages are context it injects; neither was said by anyone
+ if pl.Role == "developer" {
+ return
+ }
+ text := joinText(pl.Content)
+ if injected(text) {
+ return
+ }
+ role := "assistant"
+ if pl.Role == "user" {
+ role = "user"
+ }
+ p.add(Turn{Role: role, When: at, Text: text})
+ case "reasoning":
+ // summaries are usually encrypted and come back empty; add drops those
+ p.add(Turn{Role: "thinking", When: at, Text: joinText(pl.Summary)})
+ case "function_call", "custom_tool_call", "web_search_call":
+ name := pl.Name
+ if name == "" {
+ name = strings.TrimSuffix(pl.Type, "_call")
+ }
+ p.tools[pl.CallID] = name
+ detail := callDetail(pl.Arguments)
+ if detail == "" {
+ detail = callDetail(pl.Input)
+ }
+ p.add(Turn{Role: "tool", When: at, Tool: name, Detail: detail})
+ case "function_call_output", "custom_tool_call_output", "web_search_output":
+ p.add(Turn{Role: "result", When: at, Tool: p.tools[pl.CallID], Detail: resultDetail(pl.Output)})
+ }
+}
+
+func joinText(items []codexText) string {
+ var parts []string
+ for _, it := range items {
+ if it.Text != "" {
+ parts = append(parts, it.Text)
+ }
+ }
+ return strings.Join(parts, "\n\n")
+}
+
+// injected recognizes the wrappers a harness puts around a user turn to carry
+// its own state.
+func injected(text string) bool {
+ for _, tag := range []string{"<environment_context>", "<user_instructions>", "<permissions instructions>"} {
+ if strings.HasPrefix(text, tag) {
+ return true
+ }
+ }
+ return false
+}
+
+// ── summarizing ───────────────────────────────────────────────────────────
+
+// callFields are the arguments worth a line, most telling first. Anything that
+// carries file content (new_string, content) is deliberately not here.
+var callFields = []string{"command", "cmd", "file_path", "path", "pattern", "query", "url", "prompt", "description"}
+
+// callDetail reduces a tool call's arguments to their one interesting field.
+func callDetail(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var s string
+ if json.Unmarshal(raw, &s) == nil { // codex passes arguments as a JSON string
+ raw = json.RawMessage(s)
+ }
+ var m map[string]any
+ if json.Unmarshal(raw, &m) != nil {
+ return brief(string(raw))
+ }
+ for _, k := range callFields {
+ if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" {
+ return brief(v)
+ }
+ }
+ b, err := json.Marshal(m)
+ if err != nil {
+ return ""
+ }
+ return brief(string(b))
+}
+
+// resultDetail summarizes what a tool answered, whether that came back as a
+// string, as content blocks, or as an image nobody wants inlined.
+func resultDetail(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var s string
+ if json.Unmarshal(raw, &s) == nil {
+ return brief(s)
+ }
+ var items []codexText
+ if json.Unmarshal(raw, &items) == nil {
+ var parts []string
+ for _, it := range items {
+ switch {
+ case it.Text != "":
+ parts = append(parts, it.Text)
+ case it.Type != "":
+ parts = append(parts, "["+it.Type+"]")
+ }
+ }
+ return brief(strings.Join(parts, "\n"))
+ }
+ return brief(string(raw))
+}
+
+// brief keeps a blob's first line and says how much was left behind. Whole
+// files and command output pass through here; a page gets their shape, never
+// their bytes.
+func brief(s string) string {
+ s = strings.TrimRight(s, "\n")
+ if strings.TrimSpace(s) == "" {
+ return ""
+ }
+ first, rest, more := strings.Cut(s, "\n")
+ first = clip(strings.TrimSpace(first), briefLine)
+ if !more || strings.TrimSpace(rest) == "" {
+ return first
+ }
+ return fmt.Sprintf("%s … +%d lines", first, strings.Count(rest, "\n")+1)
+}
+
+func clip(s string, n int) string {
+ if len(s) <= n { // bytes first: the common case never allocates
+ return s
+ }
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return strings.TrimRight(string(r[:n]), " \t") + "…"
+}
+
+// ── one session: the transcript with the vr log woven in ──────────────────
+
+// A Conv is what the session page shows: the conversation, the log entries
+// that happened during it, and where in a long one we are looking.
+type Conv struct {
+ Session, Agent, Model, Dir string
+ Bit uint8
+ Harness, Path string
+ Roots []string
+ Err string
+ Reads, Greps, Notes int
+
+ Turns []Turn
+ Total int // moments in the whole conversation
+ From, To int // 1-based position of Turns within it
+ Anchor string // element id to scroll to, "" when nothing is anchored
+ AllHref string // same page, unwindowed
+ Windowed bool
+ Incomplete bool // even "show all" had to stop
+}
+
+// sessionTurns are the vr log's own moments for a session: what it read,
+// grepped and noted.
+func (s *Site) sessionTurns(id string) []Turn {
+ var out []Turn
+ for _, e := range s.Entries {
+ if e.Session != id || e.Op == "note" {
+ continue
+ }
+ out = append(out, Turn{Role: "vr", Kind: e.Op, When: parseTime(e.Time),
+ Text: describe(e), File: e.File})
+ }
+ for _, n := range s.Notes {
+ if n.Session == id {
+ out = append(out, Turn{Role: "vr", Kind: "note", When: n.At, File: n.File, Note: n})
+ }
+ }
+ return out
+}
+
+// conversation merges a session's transcript with its log entries. Both sides
+// are compared as instants — the log stamps carry an offset, transcripts are
+// UTC — so ordering is the real one, and only the rendering is local.
+func (sv *server) conversation(site *Site, id string, note int, all bool) *Conv {
+ c := &Conv{Session: id, Roots: sv.tx.roots}
+ if err := safeSession(id); err != nil {
+ c.Err = err.Error()
+ return c
+ }
+ for _, s := range site.Sessions(time.Now(), liveWindow, 0) {
+ if s.ID == id {
+ c.Agent, c.Model, c.Dir, c.Bit = s.Agent, s.Model, s.Dir, s.Bit
+ c.Reads, c.Greps, c.Notes = s.Reads, s.Greps, s.Notes
+ break
+ }
+ }
+
+ logged := site.sessionTurns(id)
+ // the parsed transcript is shared with other requests, so the merge copies
+ // it rather than appending onto its slice
+ var turns []Turn
+ if t := sv.tx.load(id, c.Dir); t != nil {
+ c.Path, c.Harness, c.Err = t.Path, t.Harness, t.Err
+ turns = make([]Turn, 0, len(t.Turns)+len(logged))
+ turns = append(turns, t.Turns...)
+ }
+ turns = append(turns, logged...)
+ sort.SliceStable(turns, func(i, j int) bool { return turns[i].When.Before(turns[j].When) })
+
+ at := -1
+ if note > 0 {
+ for i := range turns {
+ if turns[i].Note != nil && turns[i].Note.ID == note {
+ at, turns[i].Here = i, true
+ c.Anchor = fmt.Sprintf("note-%d", note)
+ break
+ }
+ }
+ }
+ c.Total = len(turns)
+ from, window := convWindowAt(turns, at, all)
+ c.Turns, c.From, c.To = window, from+1, from+len(window)
+ c.Windowed = len(window) < len(turns)
+ c.Incomplete = c.Windowed && all
+ c.AllHref = "/session/" + escPath(id) + "?all=1"
+ if note > 0 {
+ c.AllHref += fmt.Sprintf("&note=%d#note-%d", note, note)
+ }
+ return c
+}
+
+// convWindowAt cuts a conversation down to what a page may render: a stretch
+// around the anchored moment, or the tail of it when nothing is anchored.
+// "show all" raises the bound but does not remove it.
+func convWindowAt(turns []Turn, at int, all bool) (int, []Turn) {
+ n := convWindow
+ if all {
+ n = convMax
+ }
+ if len(turns) <= n {
+ return 0, turns
+ }
+ if at < 0 {
+ return len(turns) - n, turns[len(turns)-n:]
+ }
+ lo := at - n/2
+ if lo < 0 {
+ lo = 0
+ }
+ if lo+n > len(turns) {
+ lo = len(turns) - n
+ }
+ return lo, turns[lo : lo+n]
+}
diff --git a/vrsite/transcript_test.go b/vrsite/transcript_test.go
new file mode 100644
index 0000000..5e83ef4
--- /dev/null
+++ b/vrsite/transcript_test.go
@@ -0,0 +1,333 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Both fixtures are the shapes the real files use: Claude Code writes one
+// record per message with content blocks, codex wraps everything in a payload
+// and keeps the model's history in response_items.
+const claudeJSONL = `{"type":"user","timestamp":"2026-07-20T09:57:00.000Z","sessionId":"s1","cwd":"/repo","message":{"role":"user","content":"read lib.rs and tell me about the cache"}}
+{"type":"assistant","timestamp":"2026-07-20T09:57:30.000Z","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"thinking","thinking":"start with the struct","signature":"CAIS"},{"type":"text","text":"Reading it now."},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"vr read src/lib.rs:1-30","description":"read the file"}}]}}
+{"type":"user","timestamp":"2026-07-20T09:59:10.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"1\tuse std::collections::HashMap;\n2\t\n3\tstruct Cache;\n"}]}}
+{"type":"mode","timestamp":"2026-07-20T09:59:11.000Z","mode":"default"}
+{"type":"assistant","timestamp":"2026-07-20T09:59:40.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/repo/src/main.rs"}}]}}
+{"type":"user","timestamp":"2026-07-20T09:59:45.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","is_error":true,"content":[{"type":"text","text":"no such file"}]}]}}
+{"type":"assistant","timestamp":"2026-07-20T10:02:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"It is a two-level map."}]}}
+`
+
+const codexJSONL = `{"timestamp":"2026-07-20T12:00:00.000Z","type":"session_meta","payload":{"id":"019f0000-0000-7000-8000-00000000abcd","cwd":"/repo"}}
+{"timestamp":"2026-07-20T12:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"You are Codex, an agent."}]}}
+{"timestamp":"2026-07-20T12:00:02.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/repo</cwd>\n</environment_context>"}]}}
+{"timestamp":"2026-07-20T12:00:03.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the cache"}]}}
+{"timestamp":"2026-07-20T12:00:04.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"start with lib.rs"}],"encrypted_content":"gAAAA"}}
+{"timestamp":"2026-07-20T12:00:05.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"vr read src/lib.rs\",\"workdir\":\"/repo\"}"}}
+{"timestamp":"2026-07-20T12:00:06.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"Chunk ID: abc123\nOutput:\n1\tstruct Cache;\n"}}
+{"timestamp":"2026-07-20T12:00:07.000Z","type":"event_msg","payload":{"type":"agent_message","message":"the cache is two-level"}}
+{"timestamp":"2026-07-20T12:00:08.000Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"call_2","input":"*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n"}}
+{"timestamp":"2026-07-20T12:00:09.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"Exit code: 0\n"}}
+{"timestamp":"2026-07-20T12:00:10.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The cache is two-level."}]}}
+`
+
+func writeFile(t *testing.T, path, body string) string {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func turnLine(tn Turn) string {
+ s := tn.Role
+ if tn.Tool != "" {
+ s += " " + tn.Tool
+ }
+ if tn.Text != "" {
+ s += " " + tn.Text
+ }
+ if tn.Detail != "" {
+ s += " " + tn.Detail
+ }
+ return s
+}
+
+func TestParseClaudeTranscript(t *testing.T) {
+ path := writeFile(t, filepath.Join(t.TempDir(), "s.jsonl"), claudeJSONL)
+ tr := readTranscript(path, int64(len(claudeJSONL)))
+ if tr.Harness != "claude-code" || tr.Err != "" {
+ t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err)
+ }
+ want := []string{
+ "user read lib.rs and tell me about the cache",
+ "thinking start with the struct",
+ "assistant Reading it now.",
+ "tool Bash vr read src/lib.rs:1-30",
+ "result Bash 1\tuse std::collections::HashMap; … +2 lines",
+ "tool Read /repo/src/main.rs",
+ "result Read no such file",
+ "assistant It is a two-level map.",
+ }
+ var got []string
+ for _, tn := range tr.Turns {
+ got = append(got, turnLine(tn))
+ }
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+ if tr.Turns[6].Kind != "error" {
+ t.Error("a failed tool result is not marked")
+ }
+ if at := tr.Turns[0].When; at.Format(time.RFC3339) != "2026-07-20T09:57:00Z" {
+ t.Errorf("first turn at %v", at)
+ }
+}
+
+func TestParseCodexTranscript(t *testing.T) {
+ path := writeFile(t, filepath.Join(t.TempDir(), "r.jsonl"), codexJSONL)
+ tr := readTranscript(path, int64(len(codexJSONL)))
+ if tr.Harness != "codex" || tr.Err != "" {
+ t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err)
+ }
+ want := []string{
+ "user review the cache",
+ "thinking start with lib.rs",
+ "tool exec_command vr read src/lib.rs",
+ "result exec_command Chunk ID: abc123 … +2 lines",
+ "tool apply_patch *** Begin Patch … +4 lines",
+ "result apply_patch Exit code: 0",
+ "assistant The cache is two-level.",
+ }
+ var got []string
+ for _, tn := range tr.Turns {
+ got = append(got, turnLine(tn))
+ }
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+// Tool output is what makes these files megabytes; a page gets one line of it.
+func TestToolOutputIsSummarized(t *testing.T) {
+ huge := strings.Repeat("x", 400) + "\n" + strings.Repeat("line\n", 50000)
+ line := fmt.Sprintf(`{"type":"user","timestamp":"2026-07-20T10:00:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t","content":%q}]}}`, huge)
+ path := writeFile(t, filepath.Join(t.TempDir(), "big.jsonl"), line+"\n")
+ tr := readTranscript(path, int64(len(line)+1))
+ if len(tr.Turns) != 1 {
+ t.Fatalf("turns = %d", len(tr.Turns))
+ }
+ if n := len(tr.Turns[0].Detail); n > 4*briefLine {
+ t.Errorf("summary of a 250 KB result is %d bytes", n)
+ }
+ if !strings.Contains(tr.Turns[0].Detail, "+50000 lines") {
+ t.Errorf("summary does not say what was left out: %q", tr.Turns[0].Detail)
+ }
+}
+
+func TestTranscriptTooLargeIsRefused(t *testing.T) {
+ path := writeFile(t, filepath.Join(t.TempDir(), "x.jsonl"), "{}\n")
+ if tr := readTranscript(path, maxTranscript+1); !strings.Contains(tr.Err, "too large") {
+ t.Errorf("err = %q", tr.Err)
+ }
+}
+
+// ── locating ─────────────────────────────────────────────────────────────
+
+func TestSafeSession(t *testing.T) {
+ for _, ok := range []string{"s1", "web:goblin", "24983541-2e59-4d47-94bb-837acf8147c3",
+ "019fa0f1-49de-7e33-af62-6db956589080"} {
+ if err := safeSession(ok); err != nil {
+ t.Errorf("safeSession(%q) = %v", ok, err)
+ }
+ }
+ for _, bad := range []string{"", "..", "a/b", `a\b`, "../../etc/passwd", "a..b", "a b",
+ "a*b", "a\x00b", strings.Repeat("x", 129)} {
+ if err := safeSession(bad); err == nil {
+ t.Errorf("safeSession(%q) was accepted", bad)
+ }
+ }
+}
+
+func TestFindTranscript(t *testing.T) {
+ claudeRoot, codexRoot := t.TempDir(), t.TempDir()
+ const (
+ cid = "24983541-2e59-4d47-94bb-837acf8147c3"
+ xid = "019fa0f1-49de-7e33-af62-6db956589080"
+ )
+ want := writeFile(t, filepath.Join(claudeRoot, "-home-u-0x4200-cafe-proj", cid+".jsonl"), claudeJSONL)
+ wantX := writeFile(t, filepath.Join(codexRoot, "2026", "07", "26",
+ "rollout-2026-07-26T21-19-55-"+xid+".jsonl"), codexJSONL)
+ // a file outside every root must stay invisible
+ outside := writeFile(t, filepath.Join(t.TempDir(), cid+".jsonl"), claudeJSONL)
+
+ tx := newTranscripts([]string{claudeRoot, codexRoot})
+ if got := tx.find(cid, "/home/u/0x4200.cafe/proj"); got != want {
+ t.Errorf("with the logged cwd: %q, want %q", got, want)
+ }
+ if got := tx.find(cid, "/somewhere/else"); got != want {
+ t.Errorf("with a stale cwd: %q, want %q", got, want)
+ }
+ if got := tx.find(xid, ""); got != wantX {
+ t.Errorf("codex thread: %q, want %q", got, wantX)
+ }
+ if got := tx.find("nosuchsession", ""); got != "" {
+ t.Errorf("unknown session found %q", got)
+ }
+ if got := newTranscripts(nil).find(cid, filepath.Dir(outside)); got != "" {
+ t.Errorf("no roots configured, yet found %q", got)
+ }
+ if under(claudeRoot, outside) {
+ t.Error("a path outside a root passed the check")
+ }
+}
+
+// A 5 MB transcript is parsed once and then only re-read when it changes.
+func TestTranscriptCacheKeyedByFile(t *testing.T) {
+ root := t.TempDir()
+ path := writeFile(t, filepath.Join(root, "-repo", "s1.jsonl"), claudeJSONL)
+ tx := newTranscripts([]string{root})
+
+ first := tx.load("s1", "/repo")
+ if first == nil || len(first.Turns) == 0 {
+ t.Fatal("nothing parsed")
+ }
+ if again := tx.load("s1", "/repo"); again != first {
+ t.Error("an unchanged transcript was parsed twice")
+ }
+ writeFile(t, path, claudeJSONL+`{"type":"assistant","timestamp":"2026-07-20T11:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"one more thing"}]}}`+"\n")
+ future := time.Now().Add(time.Second)
+ os.Chtimes(path, future, future)
+ after := tx.load("s1", "/repo")
+ if after == first || len(after.Turns) != len(first.Turns)+1 {
+ t.Errorf("a changed transcript was not re-parsed: %d -> %d turns", len(first.Turns), len(after.Turns))
+ }
+ if tx.load("nope", "/repo") != nil {
+ t.Error("a missing transcript should be nil, not empty")
+ }
+}
+
+// ── windowing ────────────────────────────────────────────────────────────
+
+func TestConvWindowIsBounded(t *testing.T) {
+ turns := make([]Turn, 500)
+ for i := range turns {
+ turns[i] = Turn{Role: "assistant", Text: fmt.Sprint(i)}
+ }
+ from, got := convWindowAt(turns, -1, false)
+ if len(got) != convWindow || from != 500-convWindow || got[len(got)-1].Text != "499" {
+ t.Errorf("unanchored window = %d turns from %d", len(got), from)
+ }
+ from, got = convWindowAt(turns, 200, false)
+ if len(got) != convWindow || from > 200 || from+len(got) <= 200 {
+ t.Errorf("window %d..%d does not hold turn 200", from, from+len(got))
+ }
+ from, got = convWindowAt(turns, 498, false)
+ if len(got) != convWindow || from+len(got) != 500 {
+ t.Errorf("window near the end = %d..%d", from, from+len(got))
+ }
+ if _, got = convWindowAt(turns, 200, true); len(got) != 500 {
+ t.Errorf("show all gave %d of 500 turns", len(got))
+ }
+ if _, got = convWindowAt(turns[:10], -1, false); len(got) != 10 {
+ t.Errorf("short conversation was cut to %d", len(got))
+ }
+}
+
+// ── the session page ─────────────────────────────────────────────────────
+
+// sessionServer is testServer with a Claude Code transcript for session s1,
+// filed under the repo path the log entries carry.
+func sessionServer(t *testing.T) (*server, string) {
+ t.Helper()
+ sv, _, _ := testServer(t)
+ root := t.TempDir()
+ writeFile(t, filepath.Join(root, mangleDir(sv.repo), "s1.jsonl"), claudeJSONL)
+ sv.tx = newTranscripts([]string{root})
+ return sv, root
+}
+
+func TestSessionPageInterleavesLogAndTranscript(t *testing.T) {
+ sv, _ := sessionServer(t)
+ body := get(t, sv.handler(), "/session/s1?note=1", false).Body.String()
+ for _, want := range []string{
+ "read lib.rs and tell me about the cache", // the conversation
+ "It is a two-level map.",
+ "vr read src/lib.rs:1-30", // a tool call, summarized
+ "read hello.txt:1-2", // the vr log, woven in
+ "second line matters", // the note itself
+ `data-anchor="note-1"`, // and it is what the page lands on
+ `class="turn t-note here"`, // marked as the anchored moment
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("session page is missing %q", want)
+ }
+ }
+ // the whole file is never in the page
+ if strings.Contains(body, "use std::collections::HashMap;\n2") {
+ t.Error("a tool result was dumped in full")
+ }
+ // ordering: the note was logged after the first message, before the last
+ iFirst := strings.Index(body, "read lib.rs and tell me")
+ iNote := strings.Index(body, "second line matters")
+ iLast := strings.Index(body, "It is a two-level map.")
+ if !(iFirst < iNote && iNote < iLast) {
+ t.Errorf("turns are out of order: %d %d %d", iFirst, iNote, iLast)
+ }
+}
+
+func TestSessionPageWithoutATranscript(t *testing.T) {
+ sv, _, _ := testServer(t)
+ sv.tx = newTranscripts([]string{t.TempDir()})
+ body := get(t, sv.handler(), "/session/s1", false).Body.String()
+ if !strings.Contains(body, "no transcript found") {
+ t.Error("the page does not say the transcript is missing")
+ }
+ if !strings.Contains(body, sv.tx.roots[0]) {
+ t.Error("the page does not say where it looked")
+ }
+ if !strings.Contains(body, "read hello.txt:1-2") || !strings.Contains(body, "second line matters") {
+ t.Error("the vr timeline is missing from the fallback")
+ }
+}
+
+func TestSessionPageRejectsUnsafeIDs(t *testing.T) {
+ sv, _ := sessionServer(t)
+ h := sv.handler()
+ if w := get(t, h, "/session/a%20b", false); w.Code != 400 {
+ t.Errorf("GET /session/a b = %d, want 400", w.Code)
+ }
+ if w := get(t, h, "/session/unknown-session", false); w.Code != 200 ||
+ !strings.Contains(w.Body.String(), "no transcript found") {
+ t.Errorf("unknown session = %d", w.Code)
+ }
+}
+
+// Live pages link a note to its conversation; the static export has no server
+// to serve one from.
+func TestConversationLinksAreLiveOnly(t *testing.T) {
+ sv, _ := sessionServer(t)
+ h := sv.handler()
+ if b := get(t, h, "/notes", false).Body.String(); !strings.Contains(b, `href="/session/s1?note=1#note-1"`) {
+ t.Error("no conversation link on a note card")
+ }
+ if b := get(t, h, "/", false).Body.String(); !strings.Contains(b, `href="/session/s1"`) {
+ t.Error("the agents table does not link the session")
+ }
+ if b := get(t, h, "/activity", true).Body.String(); !strings.Contains(b, `href="/session/s1"`) {
+ t.Error("the activity table does not link the session")
+ }
+ if b := get(t, h, "/session/s1?note=1", false).Body.String(); strings.Contains(b, ">conversation<") {
+ t.Error("the session page links back to itself")
+ }
+ static := &page{Root: "../"}
+ if got := static.ConvHref(&Note{ID: 1, Session: "s1"}); got != "" {
+ t.Errorf("static conversation href = %q, want none", got)
+ }
+}