Details and log output for this agent run.
{"command": "cd /project && ls -la && git log --oneline -5 && git branch --show-current"}
total 64 drwxr-xr-x 6 user user 4096 Aug 25 21:59 . drwxr-xr-x 1 root root 4096 Aug 25 21:59 .. drwxr-xr-x 7 user user 4096 Aug 25 21:59 .git -rw-r--r-- 1 user user 22 Aug 25 21:59 .gitignore -rw-r--r-- 1 user user 6387 Aug 25 21:59 README.md drwxr-xr-x 3 user user 4096 Aug 25 21:59 cmd drwxr-xr-x 2 user user 4096 Aug 25 21:59 docker -rw-r--r-- 1 user user 2628 Aug 25 21:59 go.mod -rw-r--r-- 1 user user 16578 Aug 25 21:59 go.sum drwxr-xr-x 11 user user 4096 Aug 25 21:59 internal -rw-r--r-- 1 user user 3092 Aug 25 21:59 zoo.hcl.example 98298f6 Merge pull request 'Fix excessive vertical space in job view blocks (#53)' (#55) from issue-53-greg into main b75b64d Fix excessive vertical space in job view blocks 10c3dce Better Docker/git inter. 315f993 Add docker image build. 5717258 Merge pull request 'forgejo: repository allowlist with wildcard patterns' (#40) from issue-39-greg into main issue-57-greg
{"path": "."}
drwxr-xr-x 6 user user 4096 Aug 25 21:59 . drwxr-xr-x 1 root root 4096 Aug 25 21:59 .. drwxr-xr-x 7 user user 4096 Aug 25 21:59 .git -rw-r--r-- 1 user user 22 Aug 25 21:59 .gitignore -rw-r--r-- 1 user user 6387 Aug 25 21:59 README.md drwxr-xr-x 3 user user 4096 Aug 25 21:59 cmd drwxr-xr-x 2 user user 4096 Aug 25 21:59 docker -rw-r--r-- 1 user user 2628 Aug 25 21:59 go.mod -rw-r--r-- 1 user user 16578 Aug 25 21:59 go.sum drwxr-xr-x 11 user user 4096 Aug 25 21:59 internal -rw-r--r-- 1 user user 3092 Aug 25 21:59 zoo.hcl.example
{"command": "cd /project && find . -type f -not -path './.git/*' | head -100"}
./README.md ./go.sum ./cmd/zoo/main.go ./docker/group ./docker/passwd ./docker/Dockerfile ./docker/shadow ./docker/uid1000-sudo ./.gitignore ./go.mod ./internal/config/config.go ./internal/config/config_test.go ./internal/web/templates.go ./internal/web/web_test.go ./internal/web/web.go ./internal/livelog/hub.go ./internal/livelog/hub_test.go ./internal/tools/forgejo.go ./internal/tools/grep_search.go ./internal/tools/context.go ./internal/tools/remove_file.go ./internal/tools/registry.go ./internal/tools/write_file.go ./internal/tools/move_file.go ./internal/tools/read_file.go ./internal/tools/edit-file.go ./internal/tools/list_files.go ./internal/tools/bash.go ./internal/tools/shell.go ./internal/tools/tools_test.go ./internal/llm/streamer_test.go ./internal/llm/client.go ./internal/llm/streamer.go ./internal/llm/types.go ./internal/store/store.go ./internal/store/schema.sql ./internal/store/store_test.go ./internal/forgejo/watcher_test.go ./internal/forgejo/poll.go ./internal/forgejo/watcher.go ./internal/forgejo/repo_filter.go ./internal/forgejo/webhook_test.go ./internal/forgejo/webhook.go ./internal/forgejo/repo_filter_test.go ./internal/forgejo/client.go ./internal/forgejo/poll_test.go ./internal/forgejo/client_test.go ./internal/forgejo/event.go ./internal/agentrun/forgejo.go ./internal/agentrun/roster_test.go ./internal/agentrun/context.go ./internal/agentrun/system.md ./internal/agentrun/loop.go ./internal/agentrun/sandboxgit.go ./internal/agentrun/docker.go ./internal/agentrun/seed_test.go ./internal/agentrun/docker_integration_test.go ./internal/agentrun/run.go ./internal/agentrun/sandboxgit_test.go ./internal/agentrun/roster.go ./internal/scheduler/scheduler.go ./internal/scheduler/scheduler_test.go ./zoo.hcl.example
{"path": "internal/web/templates.go", "start_line": 1, "end_line": 100}
{"path":"internal/web/templates.go","content":"package web\n\nconst templates = `\n{{define \"layout_head\"}}\n\u003c!doctype html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n\u003cmeta charset=\"utf-8\"\u003e\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\n\u003ctitle\u003ezoo\u003c/title\u003e\n\u003cstyle\u003e\n :root {\n --bg: #0f0f13;\n --bg-surface: #1a1a24;\n --bg-surface-hover: #22222e;\n --bg-code: #12121a;\n --border: #2a2a3a;\n --text: #e4e4e8;\n --text-muted: #8888a0;\n --accent: #7c6aef;\n --accent-glow: rgba(124, 106, 239, 0.15);\n --radius: 12px;\n --radius-sm: 8px;\n --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n --mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;\n }\n\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n font-family: var(--font);\n background: var(--bg);\n color: var(--text);\n line-height: 1.6;\n min-height: 100vh;\n }\n\n /* ββ Navigation βββββββββββββββββββββββββββββββ */\n nav {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 2rem;\n height: 60px;\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n backdrop-filter: blur(12px);\n }\n\n nav .brand {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--text);\n text-decoration: none;\n letter-spacing: -0.02em;\n }\n\n nav .brand .logo {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n border-radius: var(--radius-sm);\n background: linear-gradient(135deg, var(--accent), #a78bfa);\n color: #fff;\n font-size: 1rem;\n font-weight: 800;\n }\n\n nav .links {\n display: flex;\n gap: 0.25rem;\n }\n\n nav .links a {\n display: inline-flex;\n align-items: center;\n gap: 0.4rem;\n padding: 0.5rem 1rem;\n border-radius: var(--radius-sm);\n color: var(--text-muted);\n text-decoration: none;\n font-size: 0.9rem;\n font-weight: 500;\n transition: all 0.15s ease;\n }\n\n nav .links a:hover {\n color: var(--text);\n background: var(--bg-surface-hover);\n }\n\n nav .links a.active {","start_line":1,"end_line":100,"total_lines":1004,"truncated":true}
{"command": "cd /project && wc -l internal/web/*.go"}
1004 internal/web/templates.go 374 internal/web/web.go 253 internal/web/web_test.go 1631 total
{"command": "cd /project && grep -n -i \"scroll\\|stream\\|spying\\|spy\" internal/web/templates.go | head -50"}
634: /* ββ Scrollbar ββββββββββββββββββββββββββββββββ */
635: ::-webkit-scrollbar { width: 8px; height: 8px; }
636: ::-webkit-scrollbar-track { background: transparent; }
637: ::-webkit-scrollbar-thumb {
641: ::-webkit-scrollbar-thumb:hover { background: #3a3a50; }
888: return (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 80);
961: var shouldScroll = nearBottom();
995: if (shouldScroll) window.scrollTo(0, document.body.scrollHeight);
{"command": "cd /project && grep -n -i \"job\" internal/web/templates.go | head -40"}
157: /* ββ Job Cards ββββββββββββββββββββββββββββββββ */
158: .job-cards {
165: .job-card {
176: .job-card:hover {
181: .job-card-header {
187: .job-card-link {
201: .job-card-link:hover {
206: .job-card-body {
212: .job-card-title {
224: .job-card-meta {
230: .job-card-meta code {
237: .job-card-agent {
244: .job-card-avatar {
253: .job-card-agent-name {
594: /* ββ Job detail meta ββββββββββββββββββββββββββ */
595: .job-meta {
602: .job-meta-item {
608: .job-meta-item .label {
616: .job-meta-item .value {
631: .job-meta { gap: 1rem; }
652: <a href="/jobs">Jobs</a>
665: {{if .ActiveJobs}}
666: <h2>Running Jobs</h2>
667: <div class="job-cards">
668: {{range .ActiveJobs}}
669: <div class="job-card">
670: <div class="job-card-header">
675: <a href="/jobs/{{.ID}}" class="job-card-link" title="View job details">β</a>
677: <div class="job-card-body">
678: <h3 class="job-card-title">
681: <p class="job-card-meta">
684: <div class="job-card-agent">
685: {{if .AvatarURL}}<img class="job-card-avatar" src="{{.AvatarURL}}" alt="{{.Agent}}" title="{{.Agent}}" loading="lazy">{{end}}
686: <span class="job-card-agent-name">{{.Agent}}</span>
757:{{define "jobs"}}
761: <h1>Jobs</h1>
780: <td><a href="/jobs/{{.ID}}">{{.ID}}</a></td>
806:{{define "job_detail"}}
810: <h1>Job {{.Job.ID}}</h1>
814: <div class="job-meta">
{"end_line": 1004, "path": "internal/web/templates.go", "start_line": 806}
{"path":"internal/web/templates.go","content":"{{define \"job_detail\"}}\n{{template \"layout_head\" .}}\n\u003cdiv class=\"container\"\u003e\n \u003cdiv class=\"page-header\"\u003e\n \u003ch1\u003eJob {{.Job.ID}}\u003c/h1\u003e\n \u003cp\u003eDetails and log output for this agent run.\u003c/p\u003e\n \u003c/div\u003e\n\n \u003cdiv class=\"job-meta\"\u003e\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eStatus\u003c/span\u003e\n \u003cspan class=\"value\"\u003e\n \u003cspan class=\"badge badge-{{.Job.Status}}\"\u003e\n \u003cspan class=\"dot\"\u003e\u003c/span\u003e\n {{.Job.Status}}\n \u003c/span\u003e\n \u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eEvent\u003c/span\u003e\n \u003cspan class=\"value\"\u003e{{.Job.EventKind}} on \u003ccode\u003e{{.Job.Owner}}/{{.Job.Repo}}#{{.Job.IssueIndex}}\u003c/code\u003e\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eAgent\u003c/span\u003e\n \u003cspan class=\"value\"\u003e\n \u003cspan class=\"agent\"\u003e\n {{if .AvatarURL}}\u003cimg class=\"agent-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Job.Agent}}\" title=\"{{.Job.Agent}}\"\u003e{{end}}\n \u003cstrong\u003e{{.Job.Agent}}\u003c/strong\u003e\n \u003c/span\u003e\n \u003c/span\u003e\n \u003c/div\u003e\n {{if .Job.Error}}\n \u003cdiv class=\"job-meta-item\"\u003e\n \u003cspan class=\"label\"\u003eError\u003c/span\u003e\n \u003cspan class=\"value error-text\"\u003e{{.Job.Error}}\u003c/span\u003e\n \u003c/div\u003e\n {{end}}\n \u003c/div\u003e\n\n \u003ch2\u003eLog\u003c/h2\u003e\n \u003cdiv class=\"log-container\" id=\"log\"\u003e\n {{range .Blocks}}\n {{if eq .Kind \"reasoning\"}}\n \u003cdiv class=\"block block-reasoning\"\u003e\n \u003cdiv class=\"block-label\"\u003eThinking\u003c/div\u003e\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\n \u003c/div\u003e\n {{else if eq .Kind \"content\"}}\n \u003cdiv class=\"block block-content\"\u003e\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\n \u003c/div\u003e\n {{else if eq .Kind \"tool\"}}\n \u003cdetails class=\"block block-tool{{if .Tool.Error}} block-tool-error{{end}}\"\u003e\n \u003csummary\u003e\n \u003cspan class=\"tool-badge\"\u003eTool\u003c/span\u003e\n \u003cspan class=\"tool-summary-text\"\u003e\n \u003cspan class=\"tool-name\"\u003eπ§ {{.Tool.Name}}\u003c/span\u003e\n \u003cspan class=\"tool-args-preview\"\u003e{{.Tool.Arguments}}\u003c/span\u003e\n \u003c/span\u003e\n \u003c/summary\u003e\n \u003cdiv class=\"block-body\"\u003e\n \u003cdiv class=\"tool-section-label\"\u003eArguments\u003c/div\u003e\n \u003cpre\u003e{{.Tool.Arguments}}\u003c/pre\u003e\n \u003cdiv class=\"tool-section-label\"\u003eResult\u003c/div\u003e\n \u003cpre\u003e{{.Tool.Result}}\u003c/pre\u003e\n \u003c/div\u003e\n \u003c/details\u003e\n {{else}}\n \u003cdiv class=\"block block-system\"\u003e{{.Text}}\u003c/div\u003e\n {{end}}\n {{end}}\n \u003c/div\u003e\n\n {{if .Live}}\n \u003cscript\u003e\n (function() {\n var jobID = {{.Job.ID}};\n var log = document.getElementById(\"log\");\n var reasoningBody = null;\n var contentBody = null;\n\n function nearBottom() {\n return (window.innerHeight + window.scrollY) \u003e= (document.body.offsetHeight - 80);\n }\n\n function newBlock(kind, label) {\n var div = document.createElement(\"div\");\n div.className = \"block block-\" + kind;\n if (label) {\n var l = document.createElement(\"div\");\n l.className = \"block-label\";\n l.textContent = label;\n div.appendChild(l);\n }\n var body = document.createElement(\"div\");\n body.className = \"block-body\";\n div.appendChild(body);\n log.appendChild(div);\n return body;\n }\n\n function newToolBlock(ev) {\n var details = document.createElement(\"details\");\n details.className = \"block block-tool\" + (ev.error ? \" block-tool-error\" : \"\");\n\n var summary = document.createElement(\"summary\");\n\n var badge = document.createElement(\"span\");\n badge.className = \"tool-badge\";\n badge.textContent = \"Tool\";\n\n var text = document.createElement(\"span\");\n text.className = \"tool-summary-text\";\n\n var name = document.createElement(\"span\");\n name.className = \"tool-name\";\n name.textContent = \"π§ \" + ev.name;\n\n var preview = document.createElement(\"span\");\n preview.className = \"tool-args-preview\";\n preview.textContent = ev.arguments;\n\n text.appendChild(name);\n text.appendChild(preview);\n summary.appendChild(badge);\n summary.appendChild(text);\n details.appendChild(summary);\n\n var body = document.createElement(\"div\");\n body.className = \"block-body\";\n\n var argsLabel = document.createElement(\"div\");\n argsLabel.className = \"tool-section-label\";\n argsLabel.textContent = \"Arguments\";\n var argsPre = document.createElement(\"pre\");\n argsPre.textContent = ev.arguments;\n\n var resultLabel = document.createElement(\"div\");\n resultLabel.className = \"tool-section-label\";\n resultLabel.textContent = \"Result\";\n var resultPre = document.createElement(\"pre\");\n resultPre.textContent = ev.result;\n\n body.appendChild(argsLabel);\n body.appendChild(argsPre);\n body.appendChild(resultLabel);\n body.appendChild(resultPre);\n details.appendChild(body);\n log.appendChild(details);\n }\n\n var es = new EventSource(\"/jobs/\" + jobID + \"/events\");\n\n es.onmessage = function(e) {\n var ev = JSON.parse(e.data);\n var shouldScroll = nearBottom();\n\n switch (ev.type) {\n case \"reasoning_start\":\n reasoningBody = newBlock(\"reasoning\", \"Thinking\");\n break;\n case \"reasoning_delta\":\n if (reasoningBody) reasoningBody.textContent += ev.text;\n break;\n case \"reasoning_end\":\n // Model output routinely starts/ends with newlines; drop them\n // so the finished block matches the static (trimmed) view.\n if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();\n reasoningBody = null;\n break;\n case \"content_start\":\n contentBody = newBlock(\"content\", null);\n break;\n case \"content_delta\":\n if (contentBody) contentBody.textContent += ev.text;\n break;\n case \"content_end\":\n if (contentBody) contentBody.textContent = contentBody.textContent.trim();\n contentBody = null;\n break;\n case \"tool\":\n newToolBlock(ev);\n break;\n case \"status\":\n es.close();\n location.reload();\n return;\n }\n\n if (shouldScroll) window.scrollTo(0, document.body.scrollHeight);\n };\n })();\n \u003c/script\u003e\n {{end}}\n\u003c/div\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n{{end}}\n`","start_line":806,"end_line":1004,"total_lines":1004,"truncated":false}
{"command": "cd /project && grep -n -i \"button\\|btn\" internal/web/templates.go | head -30"}
{"command": "cd /project && grep -n \"log-container\\|\\.log\\b\\|#log\" internal/web/templates.go | head -20"}
411: .log-container {
424: .log-container .block + .block {
846: <div class="log-container" id="log">
{"end_line": 470, "path": "internal/web/templates.go", "start_line": 380}
{"path":"internal/web/templates.go","content":" }\n\n /* ββ Info grid ββββββββββββββββββββββββββββββββ */\n .info-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n gap: 1rem;\n margin-bottom: 1rem;\n }\n\n .info-item {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n }\n\n .info-item .label {\n font-size: 0.78rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n }\n\n .info-item .value {\n font-size: 0.95rem;\n color: var(--text);\n word-break: break-all;\n }\n\n /* ββ Code / Log βββββββββββββββββββββββββββββββ */\n .log-container {\n background: var(--bg-code);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow-y: auto;\n max-height: 70vh;\n padding: 1rem;\n }\n\n /* Plain block flow, not flex: a flex column with overflow:hidden\n children (.block-tool) gives those children an automatic min-height\n of 0 instead of their content height, so once total content\n exceeded max-height, flexbox was free to squash them down. */\n .log-container .block + .block {\n margin-top: 0.6rem;\n }\n\n pre {\n margin: 0;\n padding: 1.25rem;\n font-family: var(--mono);\n font-size: 0.82rem;\n line-height: 1.7;\n color: #c4c4d0;\n white-space: pre-wrap;\n word-break: break-all;\n }\n\n /* ββ Log blocks βββββββββββββββββββββββββββββββ */\n .block-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--text-muted);\n font-weight: 600;\n margin-bottom: 0.35rem;\n }\n\n .block-body {\n font-family: var(--font);\n font-size: 0.9rem;\n line-height: 1.6;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .block-reasoning,\n .block-content {\n padding: 0.75rem 1rem;\n border-radius: var(--radius-sm);\n }\n\n .block-reasoning {\n background: rgba(124, 106, 239, 0.06);\n border-left: 3px solid var(--accent);\n }\n\n .block-reasoning .block-body {\n color: var(--text-muted);","start_line":380,"end_line":470,"total_lines":1004,"truncated":true}
{"end_line": 374, "path": "internal/web/web.go", "start_line": 1}
{"path":"internal/web/web.go","content":"// Package web is zoo's minimal server-rendered dashboard: config\n// overview, live queue/history, and a per-job log tail.\npackage web\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\ntype Server struct {\n\tcfg *config.Config\n\tstore *store.Store\n\thub *livelog.Hub\n\tfg *forgejo.Client\n\ttmpl *template.Template\n\n\tavatarMu sync.Mutex\n\tavatarCache map[string]avatarCacheEntry\n}\n\n// avatarCacheTTL bounds how long a resolved avatar URL is trusted before\n// it's re-fetched from Forgejo. Avatars rarely change, but a user can\n// re-upload one (which changes its URL), so the cache expires instead of\n// living for the process lifetime.\nconst avatarCacheTTL = time.Hour\n\ntype avatarCacheEntry struct {\n\turl string\n\tfetchedAt time.Time\n}\n\nfunc New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {\n\treturn \u0026Server{\n\t\tcfg: cfg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tfg: fg,\n\t\ttmpl: template.Must(template.New(\"\").Parse(templates)),\n\t\tavatarCache: map[string]avatarCacheEntry{},\n\t}\n}\n\n// Handler returns the dashboard's http.Handler, gated by config.Web's\n// bearer token if one is set.\nfunc (s *Server) Handler() http.Handler {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\n\n\treturn s.authMiddleware(mux)\n}\n\nfunc (s *Server) authMiddleware(next http.Handler) http.Handler {\n\tif s.cfg.Web == nil || s.cfg.Web.Token == \"\" {\n\t\treturn next\n\t}\n\n\ttoken := s.cfg.Web.Token\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tauth := r.Header.Get(\"Authorization\")\n\t\tif auth != \"Bearer \"+token {\n\t\t\tw.Header().Set(\"WWW-Authenticate\", `Bearer realm=\"zoo\"`)\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\n\t\t\treturn\n\t\t}\n\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\nfunc (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {\n\t// Fetch active (pending or running) jobs for the dashboard overview.\n\t// We fetch more than we display so we can filter to just active ones.\n\tallJobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Collect unique agent names from active jobs.\n\tvar agentNames []string\n\tseenAgents := make(map[string]bool)\n\n\tvar activeJobs []activeJobRow\n\tfor _, j := range allJobs {\n\t\tif j.Status != store.JobPending \u0026\u0026 j.Status != store.JobRunning {\n\t\t\tcontinue\n\t\t}\n\t\tif !seenAgents[j.Agent] {\n\t\t\tseenAgents[j.Agent] = true\n\t\t\tagentNames = append(agentNames, j.Agent)\n\t\t}\n\t\tactiveJobs = append(activeJobs, activeJobRow{\n\t\t\tJob: j,\n\t\t\tAvatarURL: s.avatarFor(j.Agent),\n\t\t})\n\t}\n\n\ttype indexData struct {\n\t\t*config.Config\n\t\tActiveJobs []activeJobRow\n\t}\n\n\ts.render(w, \"index\", indexData{\n\t\tConfig: s.cfg,\n\t\tActiveJobs: activeJobs,\n\t})\n}\n\n// activeJobRow is a store.Job enriched with the agent's avatar URL.\ntype activeJobRow struct {\n\tstore.Job\n\tAvatarURL string\n}\n\n// jobRow is a store.Job plus the agent's Forgejo avatar, resolved for the\n// jobs table so it's immediately clear who is running each job.\ntype jobRow struct {\n\tstore.Job\n\tAvatarURL string\n}\n\nfunc (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {\n\tjobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\trows := make([]jobRow, 0, len(jobs))\n\n\tfor _, j := range jobs {\n\t\trows = append(rows, jobRow{Job: j, AvatarURL: s.avatarFor(j.Agent)})\n\t}\n\n\ts.render(w, \"jobs\", rows)\n}\n\n// avatarFor returns the Forgejo avatar URL of the agent named username,\n// or \"\" if it can't be resolved (no Forgejo client configured, unknown\n// user, API error). The dashboard must never fail to render because of a\n// missing avatar, so every failure mode degrades to no image. Results are\n// cached per username for avatarCacheTTL so a page refresh doesn't turn\n// into one GetUserInfo call per unique agent.\nfunc (s *Server) avatarFor(username string) string {\n\tif s.fg == nil || username == \"\" {\n\t\treturn \"\"\n\t}\n\n\ts.avatarMu.Lock()\n\n\tif e, ok := s.avatarCache[username]; ok \u0026\u0026 time.Since(e.fetchedAt) \u003c avatarCacheTTL {\n\t\ts.avatarMu.Unlock()\n\n\t\treturn e.url\n\t}\n\n\ts.avatarMu.Unlock()\n\n\tprofile, err := s.fg.AgentProfile(username)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\ts.avatarMu.Lock()\n\ts.avatarCache[username] = avatarCacheEntry{url: profile.AvatarURL, fetchedAt: time.Now()}\n\ts.avatarMu.Unlock()\n\n\treturn profile.AvatarURL\n}\n\n// toolBlock is the parsed form of a stream=\"tool\" store.LogLine, for the\n// template to render as a single collapsed detail.\ntype toolBlock struct {\n\tName string\n\tArguments string\n\tResult string\n\tError bool\n}\n\n// block is one self-contained, already-complete unit of job output: a\n// finished reasoning or assistant-message block, a finished tool call,\n// or a misc system note. Unlike the old flat log view, one store.LogLine\n// maps to exactly one block β grouping/streaming happens upstream, when\n// agentrun persists the row.\ntype block struct {\n\tKind string // \"reasoning\" | \"content\" | \"tool\" | \"system\"\n\tText string\n\tTool *toolBlock\n}\n\nfunc buildBlocks(logs []store.LogLine) []block {\n\tblocks := make([]block, 0, len(logs))\n\n\tfor _, l := range logs {\n\t\tswitch l.Stream {\n\t\tcase \"reasoning\", \"content\":\n\t\t\t// Model output routinely starts/ends with newlines. The block\n\t\t\t// body renders with white-space: pre-wrap, so those would show\n\t\t\t// up as visible blank lines inflating the block's height. Trim\n\t\t\t// them for display (internal newlines are kept) and drop\n\t\t\t// blocks that are nothing but whitespace.\n\t\t\ttext := strings.TrimSpace(l.Line)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tblocks = append(blocks, block{Kind: l.Stream, Text: text})\n\n\t\tcase \"tool\":\n\t\t\tvar entry store.ToolLogEntry\n\t\t\tif err := json.Unmarshal([]byte(l.Line), \u0026entry); err != nil {\n\t\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\n\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tblocks = append(blocks, block{Kind: \"tool\", Tool: \u0026toolBlock{\n\t\t\t\tName: entry.Name,\n\t\t\t\tArguments: entry.Arguments,\n\t\t\t\tResult: entry.Result,\n\t\t\t\tError: entry.Error,\n\t\t\t}})\n\n\t\tdefault:\n\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\n\t\t}\n\t}\n\n\treturn blocks\n}\n\nfunc (s *Server) handleJobDetail(w http.ResponseWriter, r *http.Request) {\n\tid := r.PathValue(\"id\")\n\n\tjob, err := s.store.GetJob(r.Context(), id)\n\tif err != nil {\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\n\n\t\treturn\n\t}\n\n\tlogs, err := s.store.TailLogs(r.Context(), id, -1)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\ts.render(w, \"job_detail\", struct {\n\t\tJob store.Job\n\t\tBlocks []block\n\t\tLive bool\n\t\tAvatarURL string\n\t}{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning, s.avatarFor(job.Agent)})\n}\n\n// handleJobEvents streams job jobID's live output as Server-Sent\n// Events. It only ever carries events for the current, not-yet-persisted\n// block (see livelog.Hub.Checkpoint) β everything already written to\n// the store is rendered once, statically, by handleJobDetail. If the\n// job isn't live, the response just ends immediately and the client\n// falls back to the static page.\nfunc (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {\n\tid := r.PathValue(\"id\")\n\n\tjob, err := s.store.GetJob(r.Context(), id)\n\tif err != nil {\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\n\n\t\treturn\n\t}\n\n\tif job.Status != store.JobPending \u0026\u0026 job.Status != store.JobRunning {\n\t\treturn\n\t}\n\n\tflusher, ok := w.(http.Flusher)\n\tif !ok {\n\t\thttp.Error(w, \"streaming unsupported\", http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.WriteHeader(http.StatusOK)\n\n\tbuffered, ch, cancel := s.hub.Subscribe(id)\n\tdefer cancel()\n\n\twrite := func(ev livelog.Event) bool {\n\t\tdata, err := json.Marshal(ev)\n\t\tif err != nil {\n\t\t\treturn true\n\t\t}\n\n\t\tif _, err := fmt.Fprintf(w, \"data: %s\\n\\n\", data); err != nil {\n\t\t\treturn false\n\t\t}\n\n\t\tflusher.Flush()\n\n\t\treturn true\n\t}\n\n\tfor _, ev := range buffered {\n\t\tif !write(ev) {\n\t\t\treturn\n\t\t}\n\t}\n\n\theartbeat := time.NewTicker(15 * time.Second)\n\tdefer heartbeat.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase ev, ok := \u003c-ch:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !write(ev) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif ev.Type == livelog.Status {\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase \u003c-heartbeat.C:\n\t\t\tif _, err := fmt.Fprint(w, \": heartbeat\\n\\n\"); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tflusher.Flush()\n\n\t\tcase \u003c-r.Context().Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *Server) render(w http.ResponseWriter, name string, data any) {\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\n\t// Every page here reflects live, fast-changing state (job status, log\n\t// blocks). Without this, browsers are free to serve a stale cached\n\t// copy of the job detail page on a same-URL navigation β including\n\t// the auto location.reload() a job's SSE stream triggers when it\n\t// finishes β which looks exactly like blocks that were visible while\n\t// streaming vanishing once the page reloads.\n\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\n\tif err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}","start_line":1,"end_line":374,"total_lines":374,"truncated":false}
{"path": "internal/web/web_test.go", "start_line": 1, "end_line": 253}
{"path":"internal/web/web_test.go","content":"package web\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc testServer(t *testing.T) (*Server, *store.Store) {\n\tt.Helper()\n\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\tcfg := \u0026config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"leon\", LLM: \"qwen\"}},\n\t\tEvents: []config.Event{{Kind: config.EventIssueNew, Agent: \"leon\"}},\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\", MaxLive: 2},\n\t}\n\n\t// No Forgejo client: pages render without avatars.\n\treturn New(cfg, st, livelog.NewHub(), nil), st\n}\n\nfunc TestIndexRenders(t *testing.T) {\n\ts, _ := testServer(t)\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/\", nil))\n\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", rr.Code, rr.Body.String())\n\t}\n\tif !strings.Contains(rr.Body.String(), \"leon\") {\n\t\tt.Fatalf(\"expected agent name in output, got %s\", rr.Body.String())\n\t}\n}\n\nfunc TestJobsAndDetail(t *testing.T) {\n\ts, st := testServer(t)\n\tctx := context.Background()\n\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.AppendLog(ctx, \"job-1\", \"stdout\", \"hello\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"job-1\") {\n\t\tt.Fatalf(\"unexpected jobs page: %d %s\", rr.Code, rr.Body.String())\n\t}\n\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"hello\") {\n\t\tt.Fatalf(\"unexpected job detail page: %d %s\", rr.Code, rr.Body.String())\n\t}\n\n\t// job-1 was never marked started/finished, so it's still \"live\":\n\t// hitting /events should stream whatever's currently buffered in the\n\t// hub (not yet checkpointed/persisted) and then wait for more, until\n\t// the request context is done.\n\ts.hub.Publish(\"job-1\", livelog.Event{Type: livelog.ContentDelta, Text: \"streaming\"})\n\n\tevCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)\n\tdefer cancel()\n\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1/events\", nil).WithContext(evCtx))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"streaming\") {\n\t\tt.Fatalf(\"unexpected events response: %d %s\", rr.Code, rr.Body.String())\n\t}\n\n\t// A finished job isn't live: /events should return immediately with\n\t// nothing to stream, so the client falls back to the static page.\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-2\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 2}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.MarkJobFinished(ctx, \"job-2\", store.JobSucceeded, \"\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-2/events\", nil))\n\tif rr.Code != http.StatusOK || rr.Body.Len() != 0 {\n\t\tt.Fatalf(\"expected empty response for a non-live job, got %d %q\", rr.Code, rr.Body.String())\n\t}\n}\n\n// TestJobDetailTrimsBlockWhitespace verifies that reasoning/content\n// blocks are rendered without the leading/trailing newlines the model\n// routinely emits: the block body uses white-space: pre-wrap, so those\n// would show up as visible blank lines inflating the block's height\n// (issue #53). Internal newlines must be preserved.\nfunc TestJobDetailTrimsBlockWhitespace(t *testing.T) {\n\ts, st := testServer(t)\n\tctx := context.Background()\n\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.MarkJobFinished(ctx, \"job-1\", store.JobSucceeded, \"\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.AppendLog(ctx, \"job-1\", \"content\", \"\\nNow the remaining tool callers:\\n\\n\\n\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// A block that is nothing but whitespace should not render at all.\n\tif err := st.AppendLog(ctx, \"job-1\", \"content\", \" \\n\\t \"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.AppendLog(ctx, \"job-1\", \"reasoning\", \" \\ninner\\nlines\\nkept\\n \"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif r.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", r.Code, r.Body.String())\n\t}\n\n\tbody := r.Body.String()\n\tif !strings.Contains(body, `\u003cdiv class=\"block-body\"\u003eNow the remaining tool callers:\u003c/div\u003e`) {\n\t\tt.Fatalf(\"expected trimmed content block, got: %s\", body)\n\t}\n\tif strings.Contains(body, \"\u003cdiv class=\\\"block-body\\\"\u003e\\n\") {\n\t\tt.Fatalf(\"block body still starts with a newline: %s\", body)\n\t}\n\tif got := strings.Count(body, `class=\"block block-content\"`); got != 1 {\n\t\tt.Fatalf(\"expected exactly one content block (whitespace-only one dropped), got %d: %s\", got, body)\n\t}\n\tif !strings.Contains(body, \"inner\\nlines\\nkept\") {\n\t\tt.Fatalf(\"expected internal newlines to be preserved, got: %s\", body)\n\t}\n}\n\nfunc TestAuthGate(t *testing.T) {\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer st.Close()\n\n\tcfg := \u0026config.Config{Web: \u0026config.Web{Token: \"secret\"}}\n\ts := New(cfg, st, livelog.NewHub(), nil)\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\n\tif rr.Code != http.StatusUnauthorized {\n\t\tt.Fatalf(\"expected 401 without token, got %d\", rr.Code)\n\t}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/jobs\", nil)\n\treq.Header.Set(\"Authorization\", \"Bearer secret\")\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, req)\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200 with correct token, got %d\", rr.Code)\n\t}\n}\n\n// TestJobsShowAgentAvatars verifies the jobs pages render each agent's\n// Forgejo avatar, and that an unresolvable avatar (unknown user) degrades\n// to no image instead of breaking the page.\nfunc TestJobsShowAgentAvatars(t *testing.T) {\n\t// Minimal in-test Forgejo API: the version probe the SDK makes when\n\t// the client is constructed, plus per-user profile lookups.\n\tavatars := map[string]string{\n\t\t\"leon\": \"https://forgejo.example/avatars/leon\",\n\t}\n\n\tapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/api/v1/version\" {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tfmt.Fprint(w, `{\"version\":\"1.23.0\"}`)\n\n\t\t\treturn\n\t\t}\n\n\t\tif user, ok := strings.CutPrefix(r.URL.Path, \"/api/v1/users/\"); ok {\n\t\t\tif url, ok := avatars[user]; ok {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tfmt.Fprintf(w, `{\"login_name\":%q,\"avatar_url\":%q}`, user, url)\n\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\thttp.NotFound(w, r)\n\t}))\n\tt.Cleanup(api.Close)\n\n\tfg, err := forgejo.NewClient(config.Forgejo{URL: api.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\ts := New(\u0026config.Config{}, st, livelog.NewHub(), fg)\n\n\tctx := context.Background()\n\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// \"ghost\" doesn't exist on the mock Forgejo: the page must still\n\t// render, just without an avatar for that job.\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-2\", EventKind: \"issue:new\", Agent: \"ghost\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 2}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", rr.Code, rr.Body.String())\n\t}\n\n\tbody := rr.Body.String()\n\tif !strings.Contains(body, `src=\"https://forgejo.example/avatars/leon\"`) {\n\t\tt.Fatalf(\"expected leon's avatar on the jobs page, got: %s\", body)\n\t}\n\tif !strings.Contains(body, \"job-2\") {\n\t\tt.Fatalf(\"expected the unknown-agent job to still render, got: %s\", body)\n\t}\n\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `src=\"https://forgejo.example/avatars/leon\"`) {\n\t\tt.Fatalf(\"expected leon's avatar on the job detail page, got %d: %s\", rr.Code, rr.Body.String())\n\t}\n}","start_line":1,"end_line":253,"total_lines":253,"truncated":false}
{"command": "cd /project && sed -n '855,875p' internal/web/templates.go && echo '---' && sed -n '985,1004p' internal/web/templates.go"}
<div class="block-body">{{.Text}}</div>
</div>
{{else if eq .Kind "tool"}}
<details class="block block-tool{{if .Tool.Error}} block-tool-error{{end}}">
<summary>
<span class="tool-badge">Tool</span>
<span class="tool-summary-text">
<span class="tool-name">π§ {{.Tool.Name}}</span>
<span class="tool-args-preview">{{.Tool.Arguments}}</span>
</span>
</summary>
<div class="block-body">
<div class="tool-section-label">Arguments</div>
<pre>{{.Tool.Arguments}}</pre>
<div class="tool-section-label">Result</div>
<pre>{{.Tool.Result}}</pre>
</div>
</details>
{{else}}
<div class="block block-system">{{.Text}}</div>
{{end}}
---
break;
case "tool":
newToolBlock(ev);
break;
case "status":
es.close();
location.reload();
return;
}
if (shouldScroll) window.scrollTo(0, document.body.scrollHeight);
};
})();
</script>
{{end}}
</div>
</body>
</html>
{{end}}
`
{"command": "cd /project && git log --oneline -8 -- internal/web/templates.go && cat README.md | head -60"}
b75b64d Fix excessive vertical space in job view blocks 3b38675 feat(dashboard): add job overview on dashboard 7cf22ff Move max_live_agents into the environment block e1bf291 web: show agent profile photos on the jobs pages c3edf5a Add live streaming of job spying. d1315ed redesign: modern dark-themed UI for zoo dashboard 9e06288 Init. # πΎ Zoo **Autonomous AI agents that work your Forgejo issues & pull requests.** Zoo watches a [Forgejo](https://codeberg.org/forgejo/forgejo) instance for issue and pull request events, then dispatches them to configured AI agents running in isolated Docker containers. Each agent has its own role, LLM model, and personality β like a menagerie working together to keep your projects moving. --- ## β¨ Features - **Multi-agent orchestration** β Assign different tasks to specialized agents (reviewers, managers, developers). - **Event-driven routing** β Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more. - **LLM flexibility** β Plug in any OpenAI-compatible API; each agent gets its own model choice. - **Isolated execution** β Agents run in Docker containers with full filesystem access but no persistence between runs. - **Live dashboard** β Real-time web UI showing active agents, logs, and job history. - **Webhook & polling support** β React to events instantly via webhooks, or fall back to polling. --- ## π Quick Start ### Prerequisites | Requirement | Version | |-------------|---------| | Go | 1.26+ | | Docker | Latest | | Forgejo | Any (self-hosted or codeberg.dk) | | LLM endpoint | OpenAI-compatible API | ### Configuration Copy the example config and customize it: ```bash cp zoo.hcl.example zoo.hcl ``` Edit `zoo.hcl` with your Forgejo credentials, LLM tokens, and agent definitions. See the [configuration reference](#-configuration-reference) below. ### Running ```bash go build -o zoo ./cmd/zoo ./zoo ``` The daemon starts on port `:8080` by default. Open your browser to see the dashboard. --- ## π₯ Meet the Agents The example configuration includes four agents, each with a distinct role: | Agent | Role | Suggested LLM | Handles | |----------|-----------------------|---------------------|----------------------------------| | **leon** | Engineering Manager | Qwen 3.8 | New issues, comments | | **greg** | Senior Developer | Qwen 3.8 | Pull request reviews | | **anna** | UI/UX Designer | Qwen 3.6 | Design-related issues & PRs |
{"path": "internal/web/templates.go", "find": " .log-container {\n background: var(--bg-code);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow-y: auto;\n max-height: 70vh;\n padding: 1rem;\n }", "replace": " .log-container {\n background: var(--bg-code);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow-y: auto;\n max-height: 70vh;\n padding: 1rem;\n }\n\n /* The log box (not the window) is the scrollable element, so the\n jump-to-bottom button floats over it via a positioned wrapper. */\n .log-wrap {\n position: relative;\n }\n\n .log-jump {\n position: absolute;\n right: 1.5rem;\n bottom: 1.5rem;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n padding: 0.45rem 0.9rem;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: var(--bg-surface);\n color: var(--text);\n font-family: var(--font);\n font-size: 0.8rem;\n font-weight: 600;\n line-height: 1.2;\n cursor: pointer;\n box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);\n transition: background 0.15s ease, border-color 0.15s ease;\n }\n\n .log-jump:hover {\n background: var(--bg-surface-hover);\n border-color: var(--accent);\n }\n\n /* The display rule above would otherwise outrank the UA stylesheet's\n [hidden] { display: none }. */\n .log-jump[hidden] {\n display: none;\n }"}
1 occurrences replaced
{"path": "internal/web/templates.go", "find": " <h2>Log</h2>\n <div class=\"log-container\" id=\"log\">\n {{range .Blocks}}", "replace": " <h2>Log</h2>\n <div class=\"log-wrap\">\n <div class=\"log-container\" id=\"log\">\n {{range .Blocks}}"}
1 occurrences replaced
{"find": " <div class=\"block block-system\">{{.Text}}</div>\n {{end}}\n {{end}}\n </div>\n\n {{if .Live}}\n <script>", "path": "internal/web/templates.go", "replace": " <div class=\"block block-system\">{{.Text}}</div>\n {{end}}\n {{end}}\n </div>\n {{if .Live}}\n <button type=\"button\" class=\"log-jump\" id=\"log-jump\" hidden>β Latest</button>\n {{end}}\n </div>\n\n {{if .Live}}\n <script>"}
1 occurrences replaced
{"find": " (function() {\n var jobID = {{.Job.ID}};\n var log = document.getElementById(\"log\");\n var reasoningBody = null;\n var contentBody = null;\n\n function nearBottom() {\n return (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 80);\n }\n\n function newBlock(kind, label) {", "path": "internal/web/templates.go", "replace": " (function() {\n var jobID = {{.Job.ID}};\n var log = document.getElementById(\"log\");\n var jumpBtn = document.getElementById(\"log-jump\");\n var reasoningBody = null;\n var contentBody = null;\n\n // The log box (not the window) is what scrolls, so all scroll\n // math is done against it. While the view is pinned to the\n // newest output we keep it there as the stream grows; the moment\n // the user scrolls up to read earlier output we stop, and the\n // jump button reappears so they can get back to the live tail.\n var stick = true;\n\n function atBottom() {\n return log.scrollHeight - log.scrollTop - log.clientHeight <= 80;\n }\n\n function follow() {\n if (stick) log.scrollTop = log.scrollHeight;\n }\n\n log.addEventListener(\"scroll\", function() {\n stick = atBottom();\n jumpBtn.hidden = !stick;\n });\n\n jumpBtn.addEventListener(\"click\", function() {\n stick = true;\n log.scrollTop = log.scrollHeight;\n jumpBtn.hidden = true;\n });\n\n // Opening a live job means spying on its tail: start at the\n // newest output.\n follow();\n\n function newBlock(kind, label) {"}
1 occurrences replaced
{"find": " es.onmessage = function(e) {\n var ev = JSON.parse(e.data);\n var shouldScroll = nearBottom();\n\n switch (ev.type) {", "path": "internal/web/templates.go", "replace": " es.onmessage = function(e) {\n var ev = JSON.parse(e.data);\n\n switch (ev.type) {"}
1 occurrences replaced
{"find": " if (shouldScroll) window.scrollTo(0, document.body.scrollHeight);\n };", "path": "internal/web/templates.go", "replace": " follow();\n };"}
1 occurrences replaced
{"command": "cd /project && sed -n '/{{define \"job_detail\"}}/,/^{{end}}/p' internal/web/templates.go"}
{{define "job_detail"}}
{{template "layout_head" .}}
<div class="container">
<div class="page-header">
<h1>Job {{.Job.ID}}</h1>
<p>Details and log output for this agent run.</p>
</div>
<div class="job-meta">
<div class="job-meta-item">
<span class="label">Status</span>
<span class="value">
<span class="badge badge-{{.Job.Status}}">
<span class="dot"></span>
{{.Job.Status}}
</span>
</span>
</div>
<div class="job-meta-item">
<span class="label">Event</span>
<span class="value">{{.Job.EventKind}} on <code>{{.Job.Owner}}/{{.Job.Repo}}#{{.Job.IssueIndex}}</code></span>
</div>
<div class="job-meta-item">
<span class="label">Agent</span>
<span class="value">
<span class="agent">
{{if .AvatarURL}}<img class="agent-avatar" src="{{.AvatarURL}}" alt="{{.Job.Agent}}" title="{{.Job.Agent}}">{{end}}
<strong>{{.Job.Agent}}</strong>
</span>
</span>
</div>
{{if .Job.Error}}
<div class="job-meta-item">
<span class="label">Error</span>
<span class="value error-text">{{.Job.Error}}</span>
</div>
{{end}}
</div>
<h2>Log</h2>
<div class="log-wrap">
<div class="log-container" id="log">
{{range .Blocks}}
{{if eq .Kind "reasoning"}}
<div class="block block-reasoning">
<div class="block-label">Thinking</div>
<div class="block-body">{{.Text}}</div>
</div>
{{else if eq .Kind "content"}}
<div class="block block-content">
<div class="block-body">{{.Text}}</div>
</div>
{{else if eq .Kind "tool"}}
<details class="block block-tool{{if .Tool.Error}} block-tool-error{{end}}">
<summary>
<span class="tool-badge">Tool</span>
<span class="tool-summary-text">
<span class="tool-name">π§ {{.Tool.Name}}</span>
<span class="tool-args-preview">{{.Tool.Arguments}}</span>
</span>
</summary>
<div class="block-body">
<div class="tool-section-label">Arguments</div>
<pre>{{.Tool.Arguments}}</pre>
<div class="tool-section-label">Result</div>
<pre>{{.Tool.Result}}</pre>
</div>
</details>
{{else}}
<div class="block block-system">{{.Text}}</div>
{{end}}
{{end}}
</div>
{{if .Live}}
<button type="button" class="log-jump" id="log-jump" hidden>β Latest</button>
{{end}}
</div>
{{if .Live}}
<script>
(function() {
var jobID = {{.Job.ID}};
var log = document.getElementById("log");
var jumpBtn = document.getElementById("log-jump");
var reasoningBody = null;
var contentBody = null;
// The log box (not the window) is what scrolls, so all scroll
// math is done against it. While the view is pinned to the
// newest output we keep it there as the stream grows; the moment
// the user scrolls up to read earlier output we stop, and the
// jump button reappears so they can get back to the live tail.
var stick = true;
function atBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= 80;
}
function follow() {
if (stick) log.scrollTop = log.scrollHeight;
}
log.addEventListener("scroll", function() {
stick = atBottom();
jumpBtn.hidden = !stick;
});
jumpBtn.addEventListener("click", function() {
stick = true;
log.scrollTop = log.scrollHeight;
jumpBtn.hidden = true;
});
// Opening a live job means spying on its tail: start at the
// newest output.
follow();
function newBlock(kind, label) {
var div = document.createElement("div");
div.className = "block block-" + kind;
if (label) {
var l = document.createElement("div");
l.className = "block-label";
l.textContent = label;
div.appendChild(l);
}
var body = document.createElement("div");
body.className = "block-body";
div.appendChild(body);
log.appendChild(div);
return body;
}
function newToolBlock(ev) {
var details = document.createElement("details");
details.className = "block block-tool" + (ev.error ? " block-tool-error" : "");
var summary = document.createElement("summary");
var badge = document.createElement("span");
badge.className = "tool-badge";
badge.textContent = "Tool";
var text = document.createElement("span");
text.className = "tool-summary-text";
var name = document.createElement("span");
name.className = "tool-name";
name.textContent = "π§ " + ev.name;
var preview = document.createElement("span");
preview.className = "tool-args-preview";
preview.textContent = ev.arguments;
text.appendChild(name);
text.appendChild(preview);
summary.appendChild(badge);
summary.appendChild(text);
details.appendChild(summary);
var body = document.createElement("div");
body.className = "block-body";
var argsLabel = document.createElement("div");
argsLabel.className = "tool-section-label";
argsLabel.textContent = "Arguments";
var argsPre = document.createElement("pre");
argsPre.textContent = ev.arguments;
var resultLabel = document.createElement("div");
resultLabel.className = "tool-section-label";
resultLabel.textContent = "Result";
var resultPre = document.createElement("pre");
resultPre.textContent = ev.result;
body.appendChild(argsLabel);
body.appendChild(argsPre);
body.appendChild(resultLabel);
body.appendChild(resultPre);
details.appendChild(body);
log.appendChild(details);
}
var es = new EventSource("/jobs/" + jobID + "/events");
es.onmessage = function(e) {
var ev = JSON.parse(e.data);
switch (ev.type) {
case "reasoning_start":
reasoningBody = newBlock("reasoning", "Thinking");
break;
case "reasoning_delta":
if (reasoningBody) reasoningBody.textContent += ev.text;
break;
case "reasoning_end":
// Model output routinely starts/ends with newlines; drop them
// so the finished block matches the static (trimmed) view.
if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();
reasoningBody = null;
break;
case "content_start":
contentBody = newBlock("content", null);
break;
case "content_delta":
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
if (contentBody) contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
break;
case "tool":
newToolBlock(ev);
break;
case "status":
es.close();
location.reload();
return;
}
follow();
};
})();
</script>
{{end}}
</div>
</body>
</html>
{{end}}
{"find": "func TestAuthGate(t *testing.T) {", "path": "internal/web/web_test.go", "replace": "// TestJobDetailAutoScroll verifies the live job detail page ships the\n// tail-following controls (issue #57): the log box is wrapped so the\n// jump-to-bottom button can float over it, and the streaming script does\n// its scroll math against the log container (the actual scrollable\n// element) instead of the window. A finished job is static and must not\n// carry either the button or the streaming script.\nfunc TestJobDetailAutoScroll(t *testing.T) {\n\ts, st := testServer(t)\n\tctx := context.Background()\n\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif r.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", r.Code, r.Body.String())\n\t}\n\n\tbody := r.Body.String()\n\tfor _, want := range []string{\n\t\t`class=\"log-wrap\"`,\n\t\t`id=\"log-jump\"`,\n\t\t`log.scrollHeight - log.scrollTop - log.clientHeight`,\n\t\t`log.scrollTop = log.scrollHeight`,\n\t} {\n\t\tif !strings.Contains(body, want) {\n\t\t\tt.Fatalf(\"expected %q in live job detail page, got:\\n%s\", want, body)\n\t\t}\n\t}\n\t// The old window-based scroll math must be gone: it never worked,\n\t// because the log box (not the window) is what scrolls.\n\tfor _, gone := range []string{\n\t\t\"window.scrollY\",\n\t\t\"window.scrollTo\",\n\t} {\n\t\tif strings.Contains(body, gone) {\n\t\t\tt.Fatalf(\"live job detail page still uses window scroll math %q:\\n%s\", gone, body)\n\t\t}\n\t}\n\n\t// A finished job is static: no jump button, no streaming script.\n\tif err := st.MarkJobFinished(ctx, \"job-1\", store.JobSucceeded, \"\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif r.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", r.Code, r.Body.String())\n\t}\n\tif b := r.Body.String(); strings.Contains(b, `id=\"log-jump\"`) || strings.Contains(b, \"EventSource\") {\n\t\tt.Fatalf(\"finished job detail page should not carry live streaming controls:\\n%s\", b)\n\t}\n}\n\nfunc TestAuthGate(t *testing.T) {"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./internal/web/ && go test ./internal/web/ -v -run 'TestJobDetail|TestJobsAndDetail|TestIndexRenders' 2>&1 | tail -20", "timeout": 180}
=== RUN TestIndexRenders --- PASS: TestIndexRenders (0.14s) === RUN TestJobsAndDetail --- PASS: TestJobsAndDetail (0.41s) === RUN TestJobDetailTrimsBlockWhitespace --- PASS: TestJobDetailTrimsBlockWhitespace (0.21s) === RUN TestJobDetailAutoScroll --- PASS: TestJobDetailAutoScroll (0.16s) PASS ok github.com/abrander/zoo/internal/web 0.926s go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
{"command": "cd /project && go test ./... 2>&1 | tail -20", "timeout": 300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.006s ok github.com/abrander/zoo/internal/forgejo 1.137s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 2.737s ok github.com/abrander/zoo/internal/store 1.744s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.205s
{"command": "cd /project && git diff"}
diff --git a/internal/web/templates.go b/internal/web/templates.go
index c64e61b..38e1fe0 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -417,6 +417,44 @@ const templates = `
padding: 1rem;
}
+ /* The log box (not the window) is the scrollable element, so the
+ jump-to-bottom button floats over it via a positioned wrapper. */
+ .log-wrap {
+ position: relative;
+ }
+
+ .log-jump {
+ position: absolute;
+ right: 1.5rem;
+ bottom: 1.5rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.45rem 0.9rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--bg-surface);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 0.8rem;
+ font-weight: 600;
+ line-height: 1.2;
+ cursor: pointer;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
+ transition: background 0.15s ease, border-color 0.15s ease;
+ }
+
+ .log-jump:hover {
+ background: var(--bg-surface-hover);
+ border-color: var(--accent);
+ }
+
+ /* The display rule above would otherwise outrank the UA stylesheet's
+ [hidden] { display: none }. */
+ .log-jump[hidden] {
+ display: none;
+ }
+
/* Plain block flow, not flex: a flex column with overflow:hidden
children (.block-tool) gives those children an automatic min-height
of 0 instead of their content height, so once total content
@@ -843,6 +881,7 @@ const templates = `
</div>
<h2>Log</h2>
+ <div class="log-wrap">
<div class="log-container" id="log">
{{range .Blocks}}
{{if eq .Kind "reasoning"}}
@@ -875,19 +914,50 @@ const templates = `
{{end}}
{{end}}
</div>
+ {{if .Live}}
+ <button type="button" class="log-jump" id="log-jump" hidden>β Latest</button>
+ {{end}}
+ </div>
{{if .Live}}
<script>
(function() {
var jobID = {{.Job.ID}};
var log = document.getElementById("log");
+ var jumpBtn = document.getElementById("log-jump");
var reasoningBody = null;
var contentBody = null;
- function nearBottom() {
- return (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 80);
+ // The log box (not the window) is what scrolls, so all scroll
+ // math is done against it. While the view is pinned to the
+ // newest output we keep it there as the stream grows; the moment
+ // the user scrolls up to read earlier output we stop, and the
+ // jump button reappears so they can get back to the live tail.
+ var stick = true;
+
+ function atBottom() {
+ return log.scrollHeight - log.scrollTop - log.clientHeight <= 80;
+ }
+
+ function follow() {
+ if (stick) log.scrollTop = log.scrollHeight;
}
+ log.addEventListener("scroll", function() {
+ stick = atBottom();
+ jumpBtn.hidden = !stick;
+ });
+
+ jumpBtn.addEventListener("click", function() {
+ stick = true;
+ log.scrollTop = log.scrollHeight;
+ jumpBtn.hidden = true;
+ });
+
+ // Opening a live job means spying on its tail: start at the
+ // newest output.
+ follow();
+
function newBlock(kind, label) {
var div = document.createElement("div");
div.className = "block block-" + kind;
@@ -958,7 +1028,6 @@ const templates = `
es.onmessage = function(e) {
var ev = JSON.parse(e.data);
- var shouldScroll = nearBottom();
switch (ev.type) {
case "reasoning_start":
@@ -992,7 +1061,7 @@ const templates = `
return;
}
- if (shouldScroll) window.scrollTo(0, document.body.scrollHeight);
+ follow();
};
})();
</script>
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index ded09b2..27827e4 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -151,6 +151,63 @@ func TestJobDetailTrimsBlockWhitespace(t *testing.T) {
}
}
+// TestJobDetailAutoScroll verifies the live job detail page ships the
+// tail-following controls (issue #57): the log box is wrapped so the
+// jump-to-bottom button can float over it, and the streaming script does
+// its scroll math against the log container (the actual scrollable
+// element) instead of the window. A finished job is static and must not
+// carry either the button or the streaming script.
+func TestJobDetailAutoScroll(t *testing.T) {
+ s, st := testServer(t)
+ ctx := context.Background()
+
+ if err := st.CreateJob(ctx, store.Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRecorder()
+ s.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
+ if r.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", r.Code, r.Body.String())
+ }
+
+ body := r.Body.String()
+ for _, want := range []string{
+ `class="log-wrap"`,
+ `id="log-jump"`,
+ `log.scrollHeight - log.scrollTop - log.clientHeight`,
+ `log.scrollTop = log.scrollHeight`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("expected %q in live job detail page, got:\n%s", want, body)
+ }
+ }
+ // The old window-based scroll math must be gone: it never worked,
+ // because the log box (not the window) is what scrolls.
+ for _, gone := range []string{
+ "window.scrollY",
+ "window.scrollTo",
+ } {
+ if strings.Contains(body, gone) {
+ t.Fatalf("live job detail page still uses window scroll math %q:\n%s", gone, body)
+ }
+ }
+
+ // A finished job is static: no jump button, no streaming script.
+ if err := st.MarkJobFinished(ctx, "job-1", store.JobSucceeded, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ r = httptest.NewRecorder()
+ s.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
+ if r.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", r.Code, r.Body.String())
+ }
+ if b := r.Body.String(); strings.Contains(b, `id="log-jump"`) || strings.Contains(b, "EventSource") {
+ t.Fatalf("finished job detail page should not carry live streaming controls:\n%s", b)
+ }
+}
+
func TestAuthGate(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "zoo.db"))
if err != nil {
{"command": "cd /project && which node || echo \"no node\"; awk '/<script>/,/<\\/script>/' internal/web/templates.go | sed '1d;$d' | sed 's/{{.Job.ID}}/42/' > /tmp/live.js && (node --check /tmp/live.js && echo \"JS syntax OK\") 2>/dev/null || echo \"node not available, skipping\""}
no node node not available, skipping
{"command": "cd /project && cat > /tmp/render_check_test.go <<'EOF'\npackage web\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc TestRenderCheck(t *testing.T) {\n\ts, st := testServer(t)\n\tctx := context.Background()\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-9\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 9}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.AppendLog(ctx, \"job-9\", \"content\", \"line one\\nline two\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, \"/jobs/job-9\", nil))\n\tbody := r.Body.String()\n\ti := strings.Index(body, `<h2>Log</h2>`)\n\tj := strings.Index(body, `<script>`)\n\tt.Logf(\"%s\", body[i:j])\n}\nEOF\ncp /tmp/render_check_test.go internal/web/zz_render_check_test.go && go test ./internal/web/ -run TestRenderCheck -v 2>&1 | sed -n '1,40p'; rm internal/web/zz_render_check_test.go", "timeout": 120}
=== RUN TestRenderCheck
zz_render_check_test.go:27: <h2>Log</h2>
<div class="log-wrap">
<div class="log-container" id="log">
<div class="block block-content">
<div class="block-body">line one
line two</div>
</div>
</div>
<button type="button" class="log-jump" id="log-jump" hidden>β Latest</button>
</div>
--- PASS: TestRenderCheck (0.16s)
PASS
ok github.com/abrander/zoo/internal/web 0.168s
{"command": "cd /project && git add internal/web/templates.go internal/web/web_test.go && git commit -m \"Jobs page: follow the live log tail, with a jump-to-bottom button\n\nThe job detail page streams into .log-container, which is the\nscrollable element (overflow-y: auto, max-height: 70vh) β but the\nauto-scroll code did its math against the window (window.scrollY,\ndocument.body.offsetHeight, window.scrollTo), so the view never\nactually followed the stream.\n\nNow the streaming script tracks the container's scroll position:\n\n- Opening a live job starts pinned to the newest output, and the\n view keeps following as the stream grows.\n- The moment the user scrolls up to read earlier output, following\n stops (no scroll hijacking); scrolling back to the bottom resumes\n it.\n- A floating \\\"β Latest\\\" button appears over the log box whenever\n the user is scrolled up; clicking it jumps back to the tail and\n resumes following.\n\nThe button is only rendered for live jobs, and a [hidden] display\nrule keeps it out of the way otherwise.\" && git push origin issue-57-greg", "timeout": 120}
[issue-57-greg f4da8cd] Jobs page: follow the live log tail, with a jump-to-bottom button 2 files changed, 130 insertions(+), 4 deletions(-) To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-57-greg -> issue-57-greg
{"head": "issue-57-greg", "base": "main", "title": "Jobs page: follow the live log tail, with a jump-to-bottom button", "body": "## Issue #57 β Jobs page auto-scroll\n\n### Root cause\n\nThe job detail page streams into `.log-container`, which is the **scrollable element** (`overflow-y: auto; max-height: 70vh`) β but the auto-scroll code did all its math against the **window** (`window.scrollY`, `document.body.offsetHeight`, `window.scrollTo`). Since the window rarely scrolls on that page, the view never actually followed the stream: you had to scroll the log box by hand.\n\n### What this does\n\nThe streaming script now tracks the container's scroll position:\n\n- **Follow the tail** β opening a live job starts pinned to the newest output, and the view keeps following as the stream grows.\n- **No scroll hijacking** β the moment you scroll up to read earlier output, following stops. Scrolling back to the bottom (within 80px) resumes it.\n- **Jump button** β a floating \"β Latest\" button appears over the log box whenever you're scrolled up; clicking it jumps back to the tail and resumes following. It's only rendered for live jobs.\n\n### Changes\n\n- `internal/web/templates.go` β container-based `atBottom()`/`follow()` logic, scroll/click listeners, `.log-wrap` positioned wrapper + `.log-jump` button styles (including a `[hidden]` rule so the `display: inline-flex` rule can't override the UA hidden state).\n- `internal/web/web_test.go` β `TestJobDetailAutoScroll`: live page ships the wrapper, button, and container-based scroll math (and no window scroll math); finished job page ships neither button nor streaming script.\n\n`go build ./...`, `go vet`, and the full `go test ./...` suite pass."}
Pull request opened
{"body": "Done β PR opened against `main` (branch `issue-57-greg`).\n\n**Why it didn't work before:** the log box (`.log-container`) is the scrollable element (`overflow-y: auto; max-height: 70vh`), but the streaming auto-scroll code did its math against the *window* (`window.scrollY` / `window.scrollTo`), so the view never actually followed the stream.\n\n**What you get now:**\n\n- **Auto-scroll to the bottom while streaming** β opening a live job starts you at the newest output, and the view keeps following as the log grows.\n- **No hijacking** β the moment you scroll up to read earlier output, auto-scroll stops; scroll back down (or click the button) and it resumes.\n- **\"β Latest\" button** β floats over the log box whenever you're scrolled up, so you can jump straight back to the live tail. Only shown on live jobs.\n\nCovered by a new `TestJobDetailAutoScroll`; full test suite passes."}
Comment posted