summaryrefslogtreecommitdiff
path: root/web/web.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /web/web.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'web/web.go')
-rw-r--r--web/web.go245
1 files changed, 245 insertions, 0 deletions
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..ad7bd9c
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,245 @@
+// Package web is notevi's reading room: "notevi web" served or exported.
+//
+// A notevi log records who read what, where, and what they thought about it.
+// This package projects that log onto the jj change the code was read at: the repo
+// tree with per-agent coverage colors, tree-sitter highlighted sources with
+// coverage-tinted line numbers (colors mix where agents overlap), and notes —
+// both the ones agents left and the ones you write in the browser.
+//
+// It runs as a live web app (default) or writes a static export (-out).
+// Either way the log is the only state: notes are appended to a private jj
+// sidecar, never to the project working copy.
+//
+// Theme comes from a Zed theme JSON; per-agent colors come from the theme's
+// player (collaborator) palette. Every other theme this machine has — Zed's
+// and Helix's both — is offered in the picker, previewed as you move through
+// it, and remembered in the browser.
+package web
+
+import (
+ "embed"
+ "flag"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "0x4200.cafe/notevi/sidecar"
+)
+
+//go:embed templates assets
+var tfs embed.FS
+
+// Titles for a single repository's trace and for the dashboard over all of them.
+const (
+ defaultTitle = "notevi trace"
+ hubDefaultTitle = "notevi projects"
+)
+
+func fatal(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, "notevi web: "+format+"\n", a...)
+ os.Exit(1)
+}
+
+const usageText = `notevi web — the reading room for notevi trace logs.
+
+notevi logs every file a coding agent reads, greps and notes, one JSON line
+each, keyed to the jj change the code was read at. This 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.
+
+usage:
+ notevi web [-scan-root DIR] [-addr HOST:PORT] [-user NAME]
+ serve every discovered project from one dashboard; scans ~ by default
+ notevi web -repo DIR [-addr HOST:PORT] [-user NAME]
+ serve one repository directly, preserving the original single-project mode
+ notevi web [-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. open the dashboard; it discovers jj and Git repositories below ~
+ 2. initialize colocated jj or start the private sidecar from its explicit
+ button — no project registry or scan result is written to disk
+ 3. give the agents notevi and tell them to read with it; constrain/AGENTS.md
+ in the notevi repo is a drop-in brief that does, and constrain/ enforces it
+ 4. let them work, then refresh the dashboard when a new repo appears
+
+A log belongs to one repository. Entries carry the jj change id they were
+recorded at, and the site 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 private jj sidecar,
+under -user, at the exact change and commit being read. The project working
+copy is never changed, and the app keeps no state of its own.
+
+The sidecar has no bookmark and is local-only. It is not included in a fresh
+clone, so back up the jj repository or export the site when the trace matters.
+
+Session transcripts (the conversation behind a note) are looked up by session
+id under -transcripts; Claude Code and Codex are found automatically.
+
+The site is built with the two themes -theme and -light-theme name. Every
+other theme installed on this machine — Zed families and Helix themes both —
+is offered in the picker at the bottom of the tree: it previews as you move
+through it, and the browser remembers what you settled on.
+
+flags:
+`
+
+// Main runs the "notevi web" subcommand; args are everything after "web".
+func Main(args []string) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+ fs := flag.NewFlagSet("notevi web", flag.ExitOnError)
+ repo := fs.String("repo", "", "serve one jj repo directly (with -out, default: current directory)")
+ scanRoot := fs.String("scan-root", home, "live dashboard root to scan for jj and Git repositories")
+ addr := fs.String("addr", "127.0.0.1:4200", "address to serve on")
+ who := fs.String("user", "", "name your browser notes are logged under (default: $USER)")
+ out := fs.String("out", "", "write a static site here instead of serving")
+ themePath := fs.String("theme", defaultTheme("dark"), "zed theme json for the dark scheme (default: discovered, else embedded One Dark)")
+ themeName := fs.String("theme-name", "", "theme name inside the family")
+ lightPath := fs.String("light-theme", defaultTheme("light"), "zed theme json for the light scheme (default: discovered, else embedded One Light)")
+ lightName := fs.String("light-theme-name", "", "theme name inside the light family")
+ title := fs.String("title", defaultTitle, "site title")
+ roots := fs.String("transcripts", strings.Join(defaultTranscriptRoots(), ","),
+ "comma-separated roots to look for harness session transcripts in")
+ watch := fs.Bool("watch", false, "with -out: watch the trace log and rebuild after changes settle")
+ debounce := fs.Duration("debounce", 10*time.Minute, "quiet period before a watched rebuild")
+ fs.Usage = func() {
+ fmt.Fprint(fs.Output(), usageText)
+ fs.PrintDefaults()
+ }
+ fs.Parse(args)
+ if *debounce < 0 {
+ fatal("debounce must not be negative")
+ }
+ theme := loadTheme(*themePath, *themeName, "dark")
+ light := loadTheme(*lightPath, *lightName, "light")
+ themes := newThemeSet(theme, light)
+ if *out == "" && *watch {
+ fatal("-watch is for -out builds; the served app already follows the log")
+ }
+
+ // Explicit -repo and every static export retain the original one-repository
+ // path. A bare live invocation is the new, stateless project dashboard.
+ if *repo == "" && *out == "" {
+ hubTitle := *title
+ if hubTitle == defaultTitle {
+ hubTitle = hubDefaultTitle
+ }
+ hub, err := newProjectHub(*scanRoot, hubTitle, username(*who), strings.Split(*roots, ","), theme, light, themes)
+ if err != nil {
+ fatal("discover projects: %v", err)
+ }
+ serveProjects(*addr, hub)
+ return
+ }
+
+ repoPath := *repo
+ if repoPath == "" {
+ repoPath = "."
+ }
+ repoAbs, err := filepath.Abs(repoPath)
+ if err != nil {
+ fatal("%v", err)
+ }
+ log, err := sidecar.Open(repoAbs)
+ if err != nil {
+ fatal("open jj sidecar: %v", err)
+ }
+ if *out == "" {
+ serve(*addr, log, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes)
+ return
+ }
+ build := func() { buildSite(log, repoAbs, *out, *title, theme, light, themes) }
+ build()
+ if !*watch {
+ return
+ }
+ fmt.Printf("notevi web: watching %s (rebuild after %s without changes)\n", log, *debounce)
+ watchLog(log, *debounce, build)
+}
+
+func username(flagValue string) string {
+ if flagValue != "" {
+ return flagValue
+ }
+ if u := os.Getenv("USER"); u != "" {
+ return u
+ }
+ if u, err := user.Current(); err == nil && u.Username != "" {
+ return u.Username
+ }
+ return "web"
+}
+
+// ── watching the log (static export only) ─────────────────────────────────
+
+func watchLog(log logStore, debounce time.Duration, rebuild func()) {
+ watchLogUntil(log, debounce, time.Second, nil, rebuild)
+}
+
+func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) {
+ state, err := log.Generation()
+ if err != nil {
+ fatal("watch %s: %v", log, err)
+ }
+
+ ticker := time.NewTicker(poll)
+ defer ticker.Stop()
+ var timer *time.Timer
+ var timerC <-chan time.Time
+ defer func() {
+ if timer != nil {
+ timer.Stop()
+ }
+ }()
+ lastStatErr := ""
+
+ for {
+ select {
+ case <-done:
+ return
+
+ case <-ticker.C:
+ next, err := log.Generation()
+ if err != nil {
+ if msg := err.Error(); msg != lastStatErr {
+ fmt.Fprintf(os.Stderr, "notevi web: watch %s: %v\n", log, err)
+ lastStatErr = msg
+ }
+ continue
+ }
+ lastStatErr = ""
+ if next == state {
+ continue
+ }
+ state = next
+ if timer == nil {
+ timer = time.NewTimer(debounce)
+ timerC = timer.C
+ } else {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(debounce)
+ }
+ fmt.Printf("notevi web: change detected; rebuild scheduled after %s without changes\n", debounce)
+
+ case <-timerC:
+ fmt.Println("notevi web: rebuilding after trace changes settled")
+ timer = nil
+ timerC = nil
+ rebuild()
+ }
+ }
+}