summaryrefslogtreecommitdiff
path: root/web/web.go
blob: ad7bd9c5585fe71e4b700bc12a467f8df0a3212a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
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()
		}
	}
}