Details and log output for this agent run.
{"path": ".", "recursive": false}
drwxr-xr-x 6 user user 4096 Aug 25 21:38 . drwxr-xr-x 1 root root 4096 Aug 25 21:37 .. drwxr-xr-x 7 user user 4096 Aug 25 21:38 .git -rw-r--r-- 1 user user 22 Aug 25 21:38 .gitignore -rw-r--r-- 1 user user 6387 Aug 25 21:38 README.md drwxr-xr-x 3 user user 4096 Aug 25 21:38 cmd drwxr-xr-x 2 user user 4096 Aug 25 21:38 docker -rw-r--r-- 1 user user 2628 Aug 25 21:38 go.mod -rw-r--r-- 1 user user 16578 Aug 25 21:38 go.sum drwxr-xr-x 11 user user 4096 Aug 25 21:38 internal -rw-r--r-- 1 user user 3092 Aug 25 21:38 zoo.hcl.example
{"command": "cd /project && git log --oneline -5 && git status"}
10c3dce (HEAD -> issue-53-greg, origin/main, main) 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 823ae62 fix(forgejo): resolve test helper collision after merging main bccf930 Merge branch 'main' into issue-39-greg On branch issue-53-greg nothing to commit, working tree clean
{"query": "white-space", "context_after": 3, "context_before": 3}
/project/internal/web/templates.go-432- font-size: 0.82rem;
/project/internal/web/templates.go-433- line-height: 1.7;
/project/internal/web/templates.go-434- color: #c4c4d0;
/project/internal/web/templates.go:435: white-space: pre-wrap;
/project/internal/web/templates.go-436- word-break: break-all;
/project/internal/web/templates.go-437- }
/project/internal/web/templates.go-438-
--
/project/internal/web/templates.go-451- font-size: 0.9rem;
/project/internal/web/templates.go-452- line-height: 1.6;
/project/internal/web/templates.go-453- color: var(--text);
/project/internal/web/templates.go:454: white-space: pre-wrap;
/project/internal/web/templates.go-455- word-break: break-word;
/project/internal/web/templates.go-456- }
/project/internal/web/templates.go-457-
--
/project/internal/web/templates.go-547- font-weight: 400;
/project/internal/web/templates.go-548- overflow: hidden;
/project/internal/web/templates.go-549- text-overflow: ellipsis;
/project/internal/web/templates.go:550: white-space: nowrap;
/project/internal/web/templates.go-551- min-width: 0;
/project/internal/web/templates.go-552- }
/project/internal/web/templates.go-553-
--
/project/internal/web/templates.go-559- border-top: 1px solid var(--border);
/project/internal/web/templates.go-560- /* Unlike a reasoning/content block, this wraps element children
/project/internal/web/templates.go-561- (labels + <pre>s), not raw text, so it must not inherit the base
/project/internal/web/templates.go:562: .block-body's white-space: pre-wrap β that would render the
/project/internal/web/templates.go-563- template source's own whitespace between those child tags as
/project/internal/web/templates.go-564- visible blank lines. */
/project/internal/web/templates.go:565: white-space: normal;
/project/internal/web/templates.go-566- }
/project/internal/web/templates.go-567-
/project/internal/web/templates.go-568- .block-tool .tool-section-label {
{"query": "block-content|block-body", "max_results": 50}
/project/internal/web/templates.go:449: .block-body {
/project/internal/web/templates.go:459: .block-content {
/project/internal/web/templates.go:469: .block-reasoning .block-body {
/project/internal/web/templates.go:474: .block-content {
/project/internal/web/templates.go:557: .block-tool .block-body {
/project/internal/web/templates.go:562: .block-body's white-space: pre-wrap β that would render the
/project/internal/web/templates.go:851: <div class="block-body">{{.Text}}</div>
/project/internal/web/templates.go:854: <div class="block block-content">
/project/internal/web/templates.go:855: <div class="block-body">{{.Text}}</div>
/project/internal/web/templates.go:866: <div class="block-body">
/project/internal/web/templates.go:901: body.className = "block-body";
/project/internal/web/templates.go:935: body.className = "block-body";
{"path": "internal/web/templates.go", "start_line": 400, "end_line": 600}
{"path":"internal/web/templates.go","content":" color: var(--text-muted);\r\n font-weight: 600;\r\n }\r\n\r\n .info-item .value {\r\n font-size: 0.95rem;\r\n color: var(--text);\r\n word-break: break-all;\r\n }\r\n\r\n /* ββ Code / Log βββββββββββββββββββββββββββββββ */\r\n .log-container {\r\n background: var(--bg-code);\r\n border: 1px solid var(--border);\r\n border-radius: var(--radius);\r\n overflow-y: auto;\r\n max-height: 70vh;\r\n padding: 1rem;\r\n }\r\n\r\n /* Plain block flow, not flex: a flex column with overflow:hidden\r\n children (.block-tool) gives those children an automatic min-height\r\n of 0 instead of their content height, so once total content\r\n exceeded max-height, flexbox was free to squash them down. */\r\n .log-container .block + .block {\r\n margin-top: 0.6rem;\r\n }\r\n\r\n pre {\r\n margin: 0;\r\n padding: 1.25rem;\r\n font-family: var(--mono);\r\n font-size: 0.82rem;\r\n line-height: 1.7;\r\n color: #c4c4d0;\r\n white-space: pre-wrap;\r\n word-break: break-all;\r\n }\r\n\r\n /* ββ Log blocks βββββββββββββββββββββββββββββββ */\r\n .block-label {\r\n font-size: 0.72rem;\r\n text-transform: uppercase;\r\n letter-spacing: 0.06em;\r\n color: var(--text-muted);\r\n font-weight: 600;\r\n margin-bottom: 0.35rem;\r\n }\r\n\r\n .block-body {\r\n font-family: var(--font);\r\n font-size: 0.9rem;\r\n line-height: 1.6;\r\n color: var(--text);\r\n white-space: pre-wrap;\r\n word-break: break-word;\r\n }\r\n\r\n .block-reasoning,\r\n .block-content {\r\n padding: 0.75rem 1rem;\r\n border-radius: var(--radius-sm);\r\n }\r\n\r\n .block-reasoning {\r\n background: rgba(124, 106, 239, 0.06);\r\n border-left: 3px solid var(--accent);\r\n }\r\n\r\n .block-reasoning .block-body {\r\n color: var(--text-muted);\r\n font-style: italic;\r\n }\r\n\r\n .block-content {\r\n background: var(--bg-surface);\r\n border: 1px solid var(--border);\r\n }\r\n\r\n .block-system {\r\n padding: 0.35rem 0.75rem;\r\n color: var(--text-muted);\r\n font-family: var(--mono);\r\n font-size: 0.8rem;\r\n }\r\n\r\n .block-tool {\r\n background: rgba(34, 211, 238, 0.06);\r\n border: 1px solid var(--border);\r\n border-left: 4px solid #22d3ee;\r\n border-radius: var(--radius-sm);\r\n overflow: hidden;\r\n }\r\n\r\n .block-tool summary {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.75rem;\r\n cursor: pointer;\r\n padding: 0.9rem 1.1rem;\r\n min-height: 2.75rem;\r\n color: var(--text);\r\n list-style: none;\r\n }\r\n\r\n .block-tool summary::-webkit-details-marker { display: none; }\r\n\r\n .block-tool summary::before {\r\n content: \"βΈ\";\r\n display: inline-block;\r\n font-size: 1.1rem;\r\n color: var(--text-muted);\r\n transition: transform 0.15s ease;\r\n flex-shrink: 0;\r\n }\r\n\r\n .block-tool[open] summary::before { transform: rotate(90deg); }\r\n\r\n .tool-badge {\r\n flex-shrink: 0;\r\n padding: 0.25rem 0.6rem;\r\n border-radius: 999px;\r\n background: rgba(34, 211, 238, 0.15);\r\n color: #22d3ee;\r\n font-size: 0.7rem;\r\n font-weight: 700;\r\n text-transform: uppercase;\r\n letter-spacing: 0.06em;\r\n }\r\n\r\n .tool-summary-text {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.2rem;\r\n min-width: 0;\r\n }\r\n\r\n .block-tool .tool-name {\r\n font-size: 1rem;\r\n font-weight: 700;\r\n color: var(--text);\r\n }\r\n\r\n .block-tool .tool-args-preview {\r\n color: var(--text-muted);\r\n font-family: var(--mono);\r\n font-size: 0.78rem;\r\n font-weight: 400;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n min-width: 0;\r\n }\r\n\r\n .block-tool-error { border-left-color: #ef4444; }\r\n .block-tool-error .tool-badge { background: rgba(239, 68, 68, 0.15); color: #ef4444; }\r\n\r\n .block-tool .block-body {\r\n padding: 0 1.1rem 1rem;\r\n border-top: 1px solid var(--border);\r\n /* Unlike a reasoning/content block, this wraps element children\r\n (labels + \u003cpre\u003es), not raw text, so it must not inherit the base\r\n .block-body's white-space: pre-wrap β that would render the\r\n template source's own whitespace between those child tags as\r\n visible blank lines. */\r\n white-space: normal;\r\n }\r\n\r\n .block-tool .tool-section-label {\r\n font-size: 0.72rem;\r\n text-transform: uppercase;\r\n letter-spacing: 0.05em;\r\n color: var(--text-muted);\r\n font-weight: 600;\r\n margin: 0.6rem 0 0.25rem;\r\n }\r\n\r\n .block-tool pre {\r\n margin: 0;\r\n padding: 0;\r\n background: transparent;\r\n font-size: 0.8rem;\r\n color: #c4c4d0;\r\n }\r\n\r\n code {\r\n font-family: var(--mono);\r\n background: var(--bg-code);\r\n padding: 0.15rem 0.45rem;\r\n border-radius: 4px;\r\n font-size: 0.85em;\r\n color: #c4b5fd;\r\n }\r\n\r\n /* ββ Job detail meta ββββββββββββββββββββββββββ */\r\n .job-meta {\r\n display: flex;\r\n flex-wrap: wrap;\r\n gap: 1.5rem;\r\n margin-bottom: 1.5rem;\r\n }\r","start_line":400,"end_line":600,"total_lines":1000,"truncated":true}
{"end_line": 1000, "path": "internal/web/templates.go", "start_line": 800}
{"path":"internal/web/templates.go","content":" \u003c/div\u003e\r\n\u003c/div\u003e\r\n\u003c/body\u003e\r\n\u003c/html\u003e\r\n{{end}}\r\n\r\n{{define \"job_detail\"}}\r\n{{template \"layout_head\" .}}\r\n\u003cdiv class=\"container\"\u003e\r\n \u003cdiv class=\"page-header\"\u003e\r\n \u003ch1\u003eJob {{.Job.ID}}\u003c/h1\u003e\r\n \u003cp\u003eDetails and log output for this agent run.\u003c/p\u003e\r\n \u003c/div\u003e\r\n\r\n \u003cdiv class=\"job-meta\"\u003e\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eStatus\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e\r\n \u003cspan class=\"badge badge-{{.Job.Status}}\"\u003e\r\n \u003cspan class=\"dot\"\u003e\u003c/span\u003e\r\n {{.Job.Status}}\r\n \u003c/span\u003e\r\n \u003c/span\u003e\r\n \u003c/div\u003e\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eEvent\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e{{.Job.EventKind}} on \u003ccode\u003e{{.Job.Owner}}/{{.Job.Repo}}#{{.Job.IssueIndex}}\u003c/code\u003e\u003c/span\u003e\r\n \u003c/div\u003e\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eAgent\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e\r\n \u003cspan class=\"agent\"\u003e\r\n {{if .AvatarURL}}\u003cimg class=\"agent-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Job.Agent}}\" title=\"{{.Job.Agent}}\"\u003e{{end}}\r\n \u003cstrong\u003e{{.Job.Agent}}\u003c/strong\u003e\r\n \u003c/span\u003e\r\n \u003c/span\u003e\r\n \u003c/div\u003e\r\n {{if .Job.Error}}\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eError\u003c/span\u003e\r\n \u003cspan class=\"value error-text\"\u003e{{.Job.Error}}\u003c/span\u003e\r\n \u003c/div\u003e\r\n {{end}}\r\n \u003c/div\u003e\r\n\r\n \u003ch2\u003eLog\u003c/h2\u003e\r\n \u003cdiv class=\"log-container\" id=\"log\"\u003e\r\n {{range .Blocks}}\r\n {{if eq .Kind \"reasoning\"}}\r\n \u003cdiv class=\"block block-reasoning\"\u003e\r\n \u003cdiv class=\"block-label\"\u003eThinking\u003c/div\u003e\r\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\r\n \u003c/div\u003e\r\n {{else if eq .Kind \"content\"}}\r\n \u003cdiv class=\"block block-content\"\u003e\r\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\r\n \u003c/div\u003e\r\n {{else if eq .Kind \"tool\"}}\r\n \u003cdetails class=\"block block-tool{{if .Tool.Error}} block-tool-error{{end}}\"\u003e\r\n \u003csummary\u003e\r\n \u003cspan class=\"tool-badge\"\u003eTool\u003c/span\u003e\r\n \u003cspan class=\"tool-summary-text\"\u003e\r\n \u003cspan class=\"tool-name\"\u003eπ§ {{.Tool.Name}}\u003c/span\u003e\r\n \u003cspan class=\"tool-args-preview\"\u003e{{.Tool.Arguments}}\u003c/span\u003e\r\n \u003c/span\u003e\r\n \u003c/summary\u003e\r\n \u003cdiv class=\"block-body\"\u003e\r\n \u003cdiv class=\"tool-section-label\"\u003eArguments\u003c/div\u003e\r\n \u003cpre\u003e{{.Tool.Arguments}}\u003c/pre\u003e\r\n \u003cdiv class=\"tool-section-label\"\u003eResult\u003c/div\u003e\r\n \u003cpre\u003e{{.Tool.Result}}\u003c/pre\u003e\r\n \u003c/div\u003e\r\n \u003c/details\u003e\r\n {{else}}\r\n \u003cdiv class=\"block block-system\"\u003e{{.Text}}\u003c/div\u003e\r\n {{end}}\r\n {{end}}\r\n \u003c/div\u003e\r\n\r\n {{if .Live}}\r\n \u003cscript\u003e\r\n (function() {\r\n var jobID = {{.Job.ID}};\r\n var log = document.getElementById(\"log\");\r\n var reasoningBody = null;\r\n var contentBody = null;\r\n\r\n function nearBottom() {\r\n return (window.innerHeight + window.scrollY) \u003e= (document.body.offsetHeight - 80);\r\n }\r\n\r\n function newBlock(kind, label) {\r\n var div = document.createElement(\"div\");\r\n div.className = \"block block-\" + kind;\r\n if (label) {\r\n var l = document.createElement(\"div\");\r\n l.className = \"block-label\";\r\n l.textContent = label;\r\n div.appendChild(l);\r\n }\r\n var body = document.createElement(\"div\");\r\n body.className = \"block-body\";\r\n div.appendChild(body);\r\n log.appendChild(div);\r\n return body;\r\n }\r\n\r\n function newToolBlock(ev) {\r\n var details = document.createElement(\"details\");\r\n details.className = \"block block-tool\" + (ev.error ? \" block-tool-error\" : \"\");\r\n\r\n var summary = document.createElement(\"summary\");\r\n\r\n var badge = document.createElement(\"span\");\r\n badge.className = \"tool-badge\";\r\n badge.textContent = \"Tool\";\r\n\r\n var text = document.createElement(\"span\");\r\n text.className = \"tool-summary-text\";\r\n\r\n var name = document.createElement(\"span\");\r\n name.className = \"tool-name\";\r\n name.textContent = \"π§ \" + ev.name;\r\n\r\n var preview = document.createElement(\"span\");\r\n preview.className = \"tool-args-preview\";\r\n preview.textContent = ev.arguments;\r\n\r\n text.appendChild(name);\r\n text.appendChild(preview);\r\n summary.appendChild(badge);\r\n summary.appendChild(text);\r\n details.appendChild(summary);\r\n\r\n var body = document.createElement(\"div\");\r\n body.className = \"block-body\";\r\n\r\n var argsLabel = document.createElement(\"div\");\r\n argsLabel.className = \"tool-section-label\";\r\n argsLabel.textContent = \"Arguments\";\r\n var argsPre = document.createElement(\"pre\");\r\n argsPre.textContent = ev.arguments;\r\n\r\n var resultLabel = document.createElement(\"div\");\r\n resultLabel.className = \"tool-section-label\";\r\n resultLabel.textContent = \"Result\";\r\n var resultPre = document.createElement(\"pre\");\r\n resultPre.textContent = ev.result;\r\n\r\n body.appendChild(argsLabel);\r\n body.appendChild(argsPre);\r\n body.appendChild(resultLabel);\r\n body.appendChild(resultPre);\r\n details.appendChild(body);\r\n log.appendChild(details);\r\n }\r\n\r\n var es = new EventSource(\"/jobs/\" + jobID + \"/events\");\r\n\r\n es.onmessage = function(e) {\r\n var ev = JSON.parse(e.data);\r\n var shouldScroll = nearBottom();\r\n\r\n switch (ev.type) {\r\n case \"reasoning_start\":\r\n reasoningBody = newBlock(\"reasoning\", \"Thinking\");\r\n break;\r\n case \"reasoning_delta\":\r\n if (reasoningBody) reasoningBody.textContent += ev.text;\r\n break;\r\n case \"reasoning_end\":\r\n reasoningBody = null;\r\n break;\r\n case \"content_start\":\r\n contentBody = newBlock(\"content\", null);\r\n break;\r\n case \"content_delta\":\r\n if (contentBody) contentBody.textContent += ev.text;\r\n break;\r\n case \"content_end\":\r\n contentBody = null;\r\n break;\r\n case \"tool\":\r\n newToolBlock(ev);\r\n break;\r\n case \"status\":\r\n es.close();\r\n location.reload();\r\n return;\r\n }\r\n\r\n if (shouldScroll) window.scrollTo(0, document.body.scrollHeight);\r\n };\r\n })();\r\n \u003c/script\u003e\r\n {{end}}\r\n\u003c/div\u003e\r\n\u003c/body\u003e\r\n\u003c/html\u003e\r\n{{end}}\r\n`\r","start_line":800,"end_line":1000,"total_lines":1000,"truncated":false}
{"query": "Kind|Blocks", "glob": "internal/**/*.go", "max_results": 80}
No matches found.
{"command": "cd /project && grep -rn \"Blocks\" --include=\"*.go\" | head -30"}
internal/web/templates.go:847: {{range .Blocks}}
internal/web/web.go:207:func buildBlocks(logs []store.LogLine) []block {
internal/web/web.go:257: Blocks []block
internal/web/web.go:260: }{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning, s.avatarFor(job.Agent)})
{"recursive": true, "path": "internal"}
/project/internal/agentrun /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/sandboxgit.go /project/internal/agentrun/sandboxgit_test.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/poll_test.go /project/internal/forgejo/repo_filter.go /project/internal/forgejo/repo_filter_test.go /project/internal/forgejo/watcher.go /project/internal/forgejo/watcher_test.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go
{"end_line": 280, "path": "internal/web/web.go", "start_line": 150}
{"path":"internal/web/web.go","content":"\r\n\ts.render(w, \"jobs\", rows)\r\n}\r\n\r\n// avatarFor returns the Forgejo avatar URL of the agent named username,\r\n// or \"\" if it can't be resolved (no Forgejo client configured, unknown\r\n// user, API error). The dashboard must never fail to render because of a\r\n// missing avatar, so every failure mode degrades to no image. Results are\r\n// cached per username for avatarCacheTTL so a page refresh doesn't turn\r\n// into one GetUserInfo call per unique agent.\r\nfunc (s *Server) avatarFor(username string) string {\r\n\tif s.fg == nil || username == \"\" {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\ts.avatarMu.Lock()\r\n\r\n\tif e, ok := s.avatarCache[username]; ok \u0026\u0026 time.Since(e.fetchedAt) \u003c avatarCacheTTL {\r\n\t\ts.avatarMu.Unlock()\r\n\r\n\t\treturn e.url\r\n\t}\r\n\r\n\ts.avatarMu.Unlock()\r\n\r\n\tprofile, err := s.fg.AgentProfile(username)\r\n\tif err != nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\ts.avatarMu.Lock()\r\n\ts.avatarCache[username] = avatarCacheEntry{url: profile.AvatarURL, fetchedAt: time.Now()}\r\n\ts.avatarMu.Unlock()\r\n\r\n\treturn profile.AvatarURL\r\n}\r\n\r\n// toolBlock is the parsed form of a stream=\"tool\" store.LogLine, for the\r\n// template to render as a single collapsed detail.\r\ntype toolBlock struct {\r\n\tName string\r\n\tArguments string\r\n\tResult string\r\n\tError bool\r\n}\r\n\r\n// block is one self-contained, already-complete unit of job output: a\r\n// finished reasoning or assistant-message block, a finished tool call,\r\n// or a misc system note. Unlike the old flat log view, one store.LogLine\r\n// maps to exactly one block β grouping/streaming happens upstream, when\r\n// agentrun persists the row.\r\ntype block struct {\r\n\tKind string // \"reasoning\" | \"content\" | \"tool\" | \"system\"\r\n\tText string\r\n\tTool *toolBlock\r\n}\r\n\r\nfunc buildBlocks(logs []store.LogLine) []block {\r\n\tblocks := make([]block, 0, len(logs))\r\n\r\n\tfor _, l := range logs {\r\n\t\tswitch l.Stream {\r\n\t\tcase \"reasoning\", \"content\":\r\n\t\t\tblocks = append(blocks, block{Kind: l.Stream, Text: l.Line})\r\n\r\n\t\tcase \"tool\":\r\n\t\t\tvar entry store.ToolLogEntry\r\n\t\t\tif err := json.Unmarshal([]byte(l.Line), \u0026entry); err != nil {\r\n\t\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\r\n\r\n\t\t\t\tcontinue\r\n\t\t\t}\r\n\r\n\t\t\tblocks = append(blocks, block{Kind: \"tool\", Tool: \u0026toolBlock{\r\n\t\t\t\tName: entry.Name,\r\n\t\t\t\tArguments: entry.Arguments,\r\n\t\t\t\tResult: entry.Result,\r\n\t\t\t\tError: entry.Error,\r\n\t\t\t}})\r\n\r\n\t\tdefault:\r\n\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\r\n\t\t}\r\n\t}\r\n\r\n\treturn blocks\r\n}\r\n\r\nfunc (s *Server) handleJobDetail(w http.ResponseWriter, r *http.Request) {\r\n\tid := r.PathValue(\"id\")\r\n\r\n\tjob, err := s.store.GetJob(r.Context(), id)\r\n\tif err != nil {\r\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\tlogs, err := s.store.TailLogs(r.Context(), id, -1)\r\n\tif err != nil {\r\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\ts.render(w, \"job_detail\", struct {\r\n\t\tJob store.Job\r\n\t\tBlocks []block\r\n\t\tLive bool\r\n\t\tAvatarURL string\r\n\t}{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning, s.avatarFor(job.Agent)})\r\n}\r\n\r\n// handleJobEvents streams job jobID's live output as Server-Sent\r\n// Events. It only ever carries events for the current, not-yet-persisted\r\n// block (see livelog.Hub.Checkpoint) β everything already written to\r\n// the store is rendered once, statically, by handleJobDetail. If the\r\n// job isn't live, the response just ends immediately and the client\r\n// falls back to the static page.\r\nfunc (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {\r\n\tid := r.PathValue(\"id\")\r\n\r\n\tjob, err := s.store.GetJob(r.Context(), id)\r\n\tif err != nil {\r\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\tif job.Status != store.JobPending \u0026\u0026 job.Status != store.JobRunning {\r\n\t\treturn\r","start_line":150,"end_line":280,"total_lines":364,"truncated":true}
{"command": "cd /project && grep -rn \"content\\|reasoning\" internal/agentrun/run.go | grep -in \"log\\|persist\\|stream\" | head -30"}
13:317: logAppend("reasoning", reasoningBuf.String())
16:324: logAppend("content", contentBuf.String())
{"path": "internal/agentrun/run.go", "start_line": 1, "end_line": 80}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\r\n// runs in: a Docker container with the agent's git working tree cloned\r\n// inside it (authenticated with the agent's own Forgejo token), the\r\n// triggering event written to /event, and the tool-calling loop\r\n// (internal/llm + internal/tools) driven against it.\r\npackage agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t_ \"embed\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"sync\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n//go:embed system.md\r\nvar defaultSystemPrompt string\r\n\r\n// DefaultTimeout bounds a single agent run's wall-clock time if the\r\n// caller doesn't override it.\r\nconst DefaultTimeout = 120 * time.Minute\r\n\r\ntype Runner struct {\r\n\tdocker *dockerRuntime\r\n\tforgejo *forgejo.Client\r\n\tstore *store.Store\r\n\thub *livelog.Hub\r\n\tcfg *config.Config\r\n\tlogger *slog.Logger\r\n\ttimeout time.Duration\r\n\tkeepOnFailure bool\r\n\r\n\tagentClientsMu sync.Mutex\r\n\tagentClients map[string]*forgejo.Client\r\n}\r\n\r\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\r\n\tdocker, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tif timeout \u003c= 0 {\r\n\t\ttimeout = DefaultTimeout\r\n\t}\r\n\r\n\treturn \u0026Runner{\r\n\t\tdocker: docker,\r\n\t\tforgejo: fg,\r\n\t\tstore: st,\r\n\t\thub: hub,\r\n\t\tcfg: cfg,\r\n\t\tlogger: logger,\r\n\t\ttimeout: timeout,\r\n\t\tkeepOnFailure: keepOnFailure,\r\n\t\tagentClients: make(map[string]*forgejo.Client),\r\n\t}, nil\r\n}\r\n\r\n// forgejoAs returns a Forgejo client that authenticates as the given\r\n// agent (using the agent's own token from config). This lets each agent\r\n// act as themselves on Forgejo without needing a global token with sudo\r\n// privileges. Clients are built once per agent and cached, since\r\n// constructing one costs an extra API round trip.\r\n//\r\n// If the agent has no token configured, falls back to the shared zoo\r\n// identity so existing deployments without per-agent tokens still work.\r\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\r\n\tr.agentClientsMu.Lock()\r","start_line":1,"end_line":80,"total_lines":437,"truncated":true}
{"end_line": 360, "path": "internal/agentrun/run.go", "start_line": 280}
{"path":"internal/agentrun/run.go","content":"\r\n// streamHooks builds the Hooks a single Run passes to runLoop: every\r\n// delta is published live to the hub for connected dashboard viewers,\r\n// and once a reasoning/content block or tool call is complete, it's\r\n// persisted to the store as one row and the hub's replay buffer for\r\n// jobID is checkpointed β so a viewer connecting from this point on\r\n// sees it via the persisted history instead of a live replay, and is\r\n// never shown it twice.\r\nfunc (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {\r\n\tvar reasoningBuf, contentBuf strings.Builder\r\n\r\n\treasoningOpen, contentOpen := false, false\r\n\r\n\treturn Hooks{\r\n\t\tOnReasoningDelta: func(delta string) {\r\n\t\t\tif !reasoningOpen {\r\n\t\t\t\treasoningOpen = true\r\n\t\t\t\treasoningBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\r\n\t\t\t}\r\n\r\n\t\t\treasoningBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\r\n\t\t},\r\n\t\tOnContentDelta: func(delta string) {\r\n\t\t\tif !contentOpen {\r\n\t\t\t\tcontentOpen = true\r\n\t\t\t\tcontentBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\r\n\t\t\t}\r\n\r\n\t\t\tcontentBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\r\n\t\t},\r\n\t\tOnTurnEnd: func() {\r\n\t\t\tif reasoningOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\r\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\treasoningOpen = false\r\n\t\t\t}\r\n\r\n\t\t\tif contentOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\r\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\tcontentOpen = false\r\n\t\t\t}\r\n\t\t},\r\n\t\tOnTool: func(name, arguments, result string, toolErr bool) {\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{\r\n\t\t\t\tType: livelog.Tool,\r\n\t\t\t\tName: name,\r\n\t\t\t\tArguments: arguments,\r\n\t\t\t\tResult: result,\r\n\t\t\t\tError: toolErr,\r\n\t\t\t})\r\n\r\n\t\t\tline, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})\r\n\t\t\tif err != nil {\r\n\t\t\t\tr.logger.Warn(\"failed to marshal tool log entry\", \"job\", jobID, \"error\", err)\r\n\t\t\t} else {\r\n\t\t\t\tlogAppend(\"tool\", string(line))\r\n\t\t\t}\r\n\r\n\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {\r\n\traw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), \"\", \" \")\r\n\r\n\tvar instructionsSection string\r\n\tif instructions != \"\" {\r\n\t\tinstructionsSection = fmt.Sprintf(\"Instructions for this event, from zoo.hcl:\\n%s\\n\\n\", instructions)\r\n\t}\r\n\r\n\t// A pr:review run works on the PR's own head branch, not a fresh\r\n\t// branch off the default branch.\r\n\tbranchLine := fmt.Sprintf(\"Your working branch is %q, checked out from the default branch %q.\\n\\n\", branch, defaultBranch)\r","start_line":280,"end_line":360,"total_lines":437,"truncated":true}
{"command": "cd /project && grep -rn \"TailLogs\\|LogLine\" --include=\"*.go\" | grep -v \"_test\" | head -20 && echo ---- && grep -rn \"buildBlocks\" --include=\"*.go\""}
internal/web/web.go:187:// toolBlock is the parsed form of a stream="tool" store.LogLine, for the
internal/web/web.go:198:// or a misc system note. Unlike the old flat log view, one store.LogLine
internal/web/web.go:207:func buildBlocks(logs []store.LogLine) []block {
internal/web/web.go:248: logs, err := s.store.TailLogs(r.Context(), id, -1)
internal/store/store.go:309:type LogLine struct {
internal/store/store.go:316:// ToolLogEntry is the JSON shape stored in a LogLine's Line field for
internal/store/store.go:339:// TailLogs returns log lines for jobID with seq > afterSeq, oldest first.
internal/store/store.go:340:func (s *Store) TailLogs(ctx context.Context, jobID string, afterSeq int64) ([]LogLine, error) {
internal/store/store.go:349: var lines []LogLine
internal/store/store.go:351: var l LogLine
----
internal/web/web.go:207:func buildBlocks(logs []store.LogLine) []block {
internal/web/web.go:260: }{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning, s.avatarFor(job.Agent)})
{"path": "internal/web/web_test.go", "start_line": 1, "end_line": 80}
{"path":"internal/web/web_test.go","content":"package web\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"net/http\"\r\n\t\"net/http/httptest\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\nfunc testServer(t *testing.T) (*Server, *store.Store) {\r\n\tt.Helper()\r\n\r\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tt.Cleanup(func() { st.Close() })\r\n\r\n\tcfg := \u0026config.Config{\r\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\r\n\t\tAgents: []config.Agent{{Name: \"leon\", LLM: \"qwen\"}},\r\n\t\tEvents: []config.Event{{Kind: config.EventIssueNew, Agent: \"leon\"}},\r\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\", MaxLive: 2},\r\n\t}\r\n\r\n\t// No Forgejo client: pages render without avatars.\r\n\treturn New(cfg, st, livelog.NewHub(), nil), st\r\n}\r\n\r\nfunc TestIndexRenders(t *testing.T) {\r\n\ts, _ := testServer(t)\r\n\r\n\trr := httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/\", nil))\r\n\r\n\tif rr.Code != http.StatusOK {\r\n\t\tt.Fatalf(\"expected 200, got %d: %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\tif !strings.Contains(rr.Body.String(), \"leon\") {\r\n\t\tt.Fatalf(\"expected agent name in output, got %s\", rr.Body.String())\r\n\t}\r\n}\r\n\r\nfunc TestJobsAndDetail(t *testing.T) {\r\n\ts, st := testServer(t)\r\n\tctx := context.Background()\r\n\r\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif err := st.AppendLog(ctx, \"job-1\", \"stdout\", \"hello\"); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\trr := httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\r\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"job-1\") {\r\n\t\tt.Fatalf(\"unexpected jobs page: %d %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\r\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"hello\") {\r\n\t\tt.Fatalf(\"unexpected job detail page: %d %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\r\n\t// job-1 was never marked started/finished, so it's still \"live\":\r\n\t// hitting /events should stream whatever's currently buffered in the\r\n\t// hub (not yet checkpointed/persisted) and then wait for more, until\r\n\t// the request context is done.\r\n\ts.hub.Publish(\"job-1\", livelog.Event{Type: livelog.ContentDelta, Text: \"streaming\"})\r","start_line":1,"end_line":80,"total_lines":206,"truncated":true}
{"end_line": 206, "path": "internal/web/web_test.go", "start_line": 80}
{"path":"internal/web/web_test.go","content":"\ts.hub.Publish(\"job-1\", livelog.Event{Type: livelog.ContentDelta, Text: \"streaming\"})\r\n\r\n\tevCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)\r\n\tdefer cancel()\r\n\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1/events\", nil).WithContext(evCtx))\r\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"streaming\") {\r\n\t\tt.Fatalf(\"unexpected events response: %d %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\r\n\t// A finished job isn't live: /events should return immediately with\r\n\t// nothing to stream, so the client falls back to the static page.\r\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-2\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 2}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif err := st.MarkJobFinished(ctx, \"job-2\", store.JobSucceeded, \"\"); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-2/events\", nil))\r\n\tif rr.Code != http.StatusOK || rr.Body.Len() != 0 {\r\n\t\tt.Fatalf(\"expected empty response for a non-live job, got %d %q\", rr.Code, rr.Body.String())\r\n\t}\r\n}\r\n\r\nfunc TestAuthGate(t *testing.T) {\r\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tdefer st.Close()\r\n\r\n\tcfg := \u0026config.Config{Web: \u0026config.Web{Token: \"secret\"}}\r\n\ts := New(cfg, st, livelog.NewHub(), nil)\r\n\r\n\trr := httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\r\n\tif rr.Code != http.StatusUnauthorized {\r\n\t\tt.Fatalf(\"expected 401 without token, got %d\", rr.Code)\r\n\t}\r\n\r\n\treq := httptest.NewRequest(http.MethodGet, \"/jobs\", nil)\r\n\treq.Header.Set(\"Authorization\", \"Bearer secret\")\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, req)\r\n\tif rr.Code != http.StatusOK {\r\n\t\tt.Fatalf(\"expected 200 with correct token, got %d\", rr.Code)\r\n\t}\r\n}\r\n\r\n// TestJobsShowAgentAvatars verifies the jobs pages render each agent's\r\n// Forgejo avatar, and that an unresolvable avatar (unknown user) degrades\r\n// to no image instead of breaking the page.\r\nfunc TestJobsShowAgentAvatars(t *testing.T) {\r\n\t// Minimal in-test Forgejo API: the version probe the SDK makes when\r\n\t// the client is constructed, plus per-user profile lookups.\r\n\tavatars := map[string]string{\r\n\t\t\"leon\": \"https://forgejo.example/avatars/leon\",\r\n\t}\r\n\r\n\tapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\r\n\t\tif r.URL.Path == \"/api/v1/version\" {\r\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\r\n\t\t\tfmt.Fprint(w, `{\"version\":\"1.23.0\"}`)\r\n\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tif user, ok := strings.CutPrefix(r.URL.Path, \"/api/v1/users/\"); ok {\r\n\t\t\tif url, ok := avatars[user]; ok {\r\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\r\n\t\t\t\tfmt.Fprintf(w, `{\"login_name\":%q,\"avatar_url\":%q}`, user, url)\r\n\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\thttp.NotFound(w, r)\r\n\t}))\r\n\tt.Cleanup(api.Close)\r\n\r\n\tfg, err := forgejo.NewClient(config.Forgejo{URL: api.URL, Token: \"test\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tt.Cleanup(func() { st.Close() })\r\n\r\n\ts := New(\u0026config.Config{}, st, livelog.NewHub(), fg)\r\n\r\n\tctx := context.Background()\r\n\r\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\t// \"ghost\" doesn't exist on the mock Forgejo: the page must still\r\n\t// render, just without an avatar for that job.\r\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-2\", EventKind: \"issue:new\", Agent: \"ghost\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 2}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\trr := httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\r\n\tif rr.Code != http.StatusOK {\r\n\t\tt.Fatalf(\"expected 200, got %d: %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\r\n\tbody := rr.Body.String()\r\n\tif !strings.Contains(body, `src=\"https://forgejo.example/avatars/leon\"`) {\r\n\t\tt.Fatalf(\"expected leon's avatar on the jobs page, got: %s\", body)\r\n\t}\r\n\tif !strings.Contains(body, \"job-2\") {\r\n\t\tt.Fatalf(\"expected the unknown-agent job to still render, got: %s\", body)\r\n\t}\r\n\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\r\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `src=\"https://forgejo.example/avatars/leon\"`) {\r\n\t\tt.Fatalf(\"expected leon's avatar on the job detail page, got %d: %s\", rr.Code, rr.Body.String())\r\n\t}\r\n}\r","start_line":80,"end_line":206,"total_lines":206,"truncated":false}
{"end_line": 150, "path": "internal/web/web.go", "start_line": 1}
{"path":"internal/web/web.go","content":"// Package web is zoo's minimal server-rendered dashboard: config\r\n// overview, live queue/history, and a per-job log tail.\r\npackage web\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"html/template\"\r\n\t\"net/http\"\r\n\t\"sync\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\ntype Server struct {\r\n\tcfg *config.Config\r\n\tstore *store.Store\r\n\thub *livelog.Hub\r\n\tfg *forgejo.Client\r\n\ttmpl *template.Template\r\n\r\n\tavatarMu sync.Mutex\r\n\tavatarCache map[string]avatarCacheEntry\r\n}\r\n\r\n// avatarCacheTTL bounds how long a resolved avatar URL is trusted before\r\n// it's re-fetched from Forgejo. Avatars rarely change, but a user can\r\n// re-upload one (which changes its URL), so the cache expires instead of\r\n// living for the process lifetime.\r\nconst avatarCacheTTL = time.Hour\r\n\r\ntype avatarCacheEntry struct {\r\n\turl string\r\n\tfetchedAt time.Time\r\n}\r\n\r\nfunc New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {\r\n\treturn \u0026Server{\r\n\t\tcfg: cfg,\r\n\t\tstore: st,\r\n\t\thub: hub,\r\n\t\tfg: fg,\r\n\t\ttmpl: template.Must(template.New(\"\").Parse(templates)),\r\n\t\tavatarCache: map[string]avatarCacheEntry{},\r\n\t}\r\n}\r\n\r\n// Handler returns the dashboard's http.Handler, gated by config.Web's\r\n// bearer token if one is set.\r\nfunc (s *Server) Handler() http.Handler {\r\n\tmux := http.NewServeMux()\r\n\r\n\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\r\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\r\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\r\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\r\n\r\n\treturn s.authMiddleware(mux)\r\n}\r\n\r\nfunc (s *Server) authMiddleware(next http.Handler) http.Handler {\r\n\tif s.cfg.Web == nil || s.cfg.Web.Token == \"\" {\r\n\t\treturn next\r\n\t}\r\n\r\n\ttoken := s.cfg.Web.Token\r\n\r\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\r\n\t\tauth := r.Header.Get(\"Authorization\")\r\n\t\tif auth != \"Bearer \"+token {\r\n\t\t\tw.Header().Set(\"WWW-Authenticate\", `Bearer realm=\"zoo\"`)\r\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\r\n\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tnext.ServeHTTP(w, r)\r\n\t})\r\n}\r\n\r\nfunc (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {\r\n\t// Fetch active (pending or running) jobs for the dashboard overview.\r\n\t// We fetch more than we display so we can filter to just active ones.\r\n\tallJobs, err := s.store.ListJobs(r.Context(), 200)\r\n\tif err != nil {\r\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\r\n\t\treturn\r\n\t}\r\n\r\n\t// Collect unique agent names from active jobs.\r\n\tvar agentNames []string\r\n\tseenAgents := make(map[string]bool)\r\n\r\n\tvar activeJobs []activeJobRow\r\n\tfor _, j := range allJobs {\r\n\t\tif j.Status != store.JobPending \u0026\u0026 j.Status != store.JobRunning {\r\n\t\t\tcontinue\r\n\t\t}\r\n\t\tif !seenAgents[j.Agent] {\r\n\t\t\tseenAgents[j.Agent] = true\r\n\t\t\tagentNames = append(agentNames, j.Agent)\r\n\t\t}\r\n\t\tactiveJobs = append(activeJobs, activeJobRow{\r\n\t\t\tJob: j,\r\n\t\t\tAvatarURL: s.avatarFor(j.Agent),\r\n\t\t})\r\n\t}\r\n\r\n\ttype indexData struct {\r\n\t\t*config.Config\r\n\t\tActiveJobs []activeJobRow\r\n\t}\r\n\r\n\ts.render(w, \"index\", indexData{\r\n\t\tConfig: s.cfg,\r\n\t\tActiveJobs: activeJobs,\r\n\t})\r\n}\r\n\r\n// activeJobRow is a store.Job enriched with the agent's avatar URL.\r\ntype activeJobRow struct {\r\n\tstore.Job\r\n\tAvatarURL string\r\n}\r\n\r\n// jobRow is a store.Job plus the agent's Forgejo avatar, resolved for the\r\n// jobs table so it's immediately clear who is running each job.\r\ntype jobRow struct {\r\n\tstore.Job\r\n\tAvatarURL string\r\n}\r\n\r\nfunc (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {\r\n\tjobs, err := s.store.ListJobs(r.Context(), 200)\r\n\tif err != nil {\r\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\trows := make([]jobRow, 0, len(jobs))\r\n\r\n\tfor _, j := range jobs {\r\n\t\trows = append(rows, jobRow{Job: j, AvatarURL: s.avatarFor(j.Agent)})\r\n\t}\r\n\r","start_line":1,"end_line":150,"total_lines":364,"truncated":true}
{"end_line": 380, "path": "internal/store/store.go", "start_line": 300}
{"path":"internal/store/store.go","content":"\t\t\treturn nil, fmt.Errorf(\"list jobs: %w\", err)\r\n\t\t}\r\n\r\n\t\tjobs = append(jobs, j)\r\n\t}\r\n\r\n\treturn jobs, rows.Err()\r\n}\r\n\r\ntype LogLine struct {\r\n\tSeq int64\r\n\tStream string // \"reasoning\" | \"content\" | \"tool\" | \"system\"\r\n\tLine string\r\n\tTS time.Time\r\n}\r\n\r\n// ToolLogEntry is the JSON shape stored in a LogLine's Line field for\r\n// Stream == \"tool\": one complete tool call and its result.\r\ntype ToolLogEntry struct {\r\n\tName string `json:\"name\"`\r\n\tArguments string `json:\"arguments\"`\r\n\tResult string `json:\"result\"`\r\n\tError bool `json:\"error\"`\r\n}\r\n\r\n// AppendLog appends one log line for a job, assigning it the next\r\n// sequence number for that job.\r\nfunc (s *Store) AppendLog(ctx context.Context, jobID, stream, line string) error {\r\n\t_, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT INTO job_logs (job_id, seq, stream, line, ts)\r\n\t\t VALUES (?, COALESCE((SELECT MAX(seq) + 1 FROM job_logs WHERE job_id = ?), 0), ?, ?, ?)`,\r\n\t\tjobID, jobID, stream, line, time.Now().UTC())\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"append log: %w\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// TailLogs returns log lines for jobID with seq \u003e afterSeq, oldest first.\r\nfunc (s *Store) TailLogs(ctx context.Context, jobID string, afterSeq int64) ([]LogLine, error) {\r\n\trows, err := s.db.QueryContext(ctx,\r\n\t\t`SELECT seq, stream, line, ts FROM job_logs WHERE job_id = ? AND seq \u003e ? ORDER BY seq ASC`,\r\n\t\tjobID, afterSeq)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"tail logs: %w\", err)\r\n\t}\r\n\tdefer rows.Close()\r\n\r\n\tvar lines []LogLine\r\n\tfor rows.Next() {\r\n\t\tvar l LogLine\r\n\t\tif err := rows.Scan(\u0026l.Seq, \u0026l.Stream, \u0026l.Line, \u0026l.TS); err != nil {\r\n\t\t\treturn nil, fmt.Errorf(\"tail logs: %w\", err)\r\n\t\t}\r\n\r\n\t\tlines = append(lines, l)\r\n\t}\r\n\r\n\treturn lines, rows.Err()\r\n}\r","start_line":300,"end_line":360,"total_lines":360,"truncated":false}
{"path": "internal/web/web.go", "find": "import (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"", "replace": "import (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\""}
1 occurrences replaced
{"path": "internal/web/web.go", "find": "\tfor _, l := range logs {\n\t\tswitch l.Stream {\n\t\tcase \"reasoning\", \"content\":\n\t\t\tblocks = append(blocks, block{Kind: l.Stream, Text: l.Line})", "replace": "\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})"}
1 occurrences replaced
{"path": "internal/web/templates.go", "find": " case \"reasoning_end\":\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 contentBody = null;\n break;", "replace": " 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;"}
1 occurrences replaced
{"path": "internal/web/web_test.go", "find": "func TestAuthGate(t *testing.T) {", "replace": "// 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, `<div class=\"block-body\">Now the remaining tool callers:</div>`) {\n\t\tt.Fatalf(\"expected trimmed content block, got: %s\", body)\n\t}\n\tif strings.Contains(body, \"<div class=\\\"block-body\\\">\\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) {"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./internal/web/ && go test ./internal/web/ -run TestJobDetail -v 2>&1 | tail -20"}
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 === RUN TestJobDetailTrimsBlockWhitespace --- PASS: TestJobDetailTrimsBlockWhitespace (0.22s) PASS ok github.com/abrander/zoo/internal/web 0.227s
{"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.005s ok github.com/abrander/zoo/internal/forgejo 1.278s 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.917s ok github.com/abrander/zoo/internal/store 1.840s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.008s
{"command": "cd /project && git diff"}
diff --git a/internal/web/templates.go b/internal/web/templates.go
index dd63248..c64e61b 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -968,6 +968,9 @@ const templates = `
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":
@@ -977,6 +980,7 @@ const templates = `
if (contentBody) contentBody.textContent += ev.text;
break;
case "content_end":
+ if (contentBody) contentBody.textContent = contentBody.textContent.trim();
contentBody = null;
break;
case "tool":
diff --git a/internal/web/web.go b/internal/web/web.go
index eeec747..55ab97f 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -7,6 +7,7 @@ import (
"fmt"
"html/template"
"net/http"
+ "strings"
"sync"
"time"
@@ -210,7 +211,16 @@ func buildBlocks(logs []store.LogLine) []block {
for _, l := range logs {
switch l.Stream {
case "reasoning", "content":
- blocks = append(blocks, block{Kind: l.Stream, Text: l.Line})
+ // Model output routinely starts/ends with newlines. The block
+ // body renders with white-space: pre-wrap, so those would show
+ // up as visible blank lines inflating the block's height. Trim
+ // them for display (internal newlines are kept) and drop
+ // blocks that are nothing but whitespace.
+ text := strings.TrimSpace(l.Line)
+ if text == "" {
+ continue
+ }
+ blocks = append(blocks, block{Kind: l.Stream, Text: text})
case "tool":
var entry store.ToolLogEntry
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 88974a8..ded09b2 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -104,6 +104,53 @@ func TestJobsAndDetail(t *testing.T) {
}
}
+// TestJobDetailTrimsBlockWhitespace verifies that reasoning/content
+// blocks are rendered without the leading/trailing newlines the model
+// routinely emits: the block body uses white-space: pre-wrap, so those
+// would show up as visible blank lines inflating the block's height
+// (issue #53). Internal newlines must be preserved.
+func TestJobDetailTrimsBlockWhitespace(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)
+ }
+ if err := st.MarkJobFinished(ctx, "job-1", store.JobSucceeded, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.AppendLog(ctx, "job-1", "content", "\nNow the remaining tool callers:\n\n\n"); err != nil {
+ t.Fatal(err)
+ }
+ // A block that is nothing but whitespace should not render at all.
+ if err := st.AppendLog(ctx, "job-1", "content", " \n\t "); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.AppendLog(ctx, "job-1", "reasoning", " \ninner\nlines\nkept\n "); 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()
+ if !strings.Contains(body, `<div class="block-body">Now the remaining tool callers:</div>`) {
+ t.Fatalf("expected trimmed content block, got: %s", body)
+ }
+ if strings.Contains(body, "<div class=\"block-body\">\n") {
+ t.Fatalf("block body still starts with a newline: %s", body)
+ }
+ if got := strings.Count(body, `class="block block-content"`); got != 1 {
+ t.Fatalf("expected exactly one content block (whitespace-only one dropped), got %d: %s", got, body)
+ }
+ if !strings.Contains(body, "inner\nlines\nkept") {
+ t.Fatalf("expected internal newlines to be preserved, got: %s", body)
+ }
+}
+
func TestAuthGate(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "zoo.db"))
if err != nil {
{"command": "cd /project && git add -A && git commit -m \"Fix excessive vertical space in job view blocks\n\nModel reasoning/content output routinely starts/ends with newlines,\nwhich white-space: pre-wrap in .block-body renders as visible blank\nlines, inflating each block's height.\n\nTrim leading/trailing whitespace for display in buildBlocks (internal\nnewlines are preserved; whitespace-only blocks are dropped entirely),\nand trim the accumulated text in the live SSE view when a block ends\nso it matches the static view. The stored log keeps the raw model\noutput; this is a display-layer fix, so existing jobs are covered too.\n\nFixes #53\" && git push -u origin issue-53-greg"}
[issue-53-greg b75b64d] Fix excessive vertical space in job view blocks 3 files changed, 62 insertions(+), 1 deletion(-) Enumerating objects: 13, done. Counting objects: 7% (1/13) Counting objects: 15% (2/13) Counting objects: 23% (3/13) Counting objects: 30% (4/13) Counting objects: 38% (5/13) Counting objects: 46% (6/13) Counting objects: 53% (7/13) Counting objects: 61% (8/13) Counting objects: 69% (9/13) Counting objects: 76% (10/13) Counting objects: 84% (11/13) Counting objects: 92% (12/13) Counting objects: 100% (13/13) Counting objects: 100% (13/13), done. Delta compression using up to 32 threads Compressing objects: 14% (1/7) Compressing objects: 28% (2/7) Compressing objects: 42% (3/7) Compressing objects: 57% (4/7) Compressing objects: 71% (5/7) Compressing objects: 85% (6/7) Compressing objects: 100% (7/7) Compressing objects: 100% (7/7), done. Writing objects: 14% (1/7) Writing objects: 28% (2/7) Writing objects: 42% (3/7) Writing objects: 57% (4/7) Writing objects: 71% (5/7) Writing objects: 85% (6/7) Writing objects: 100% (7/7) Writing objects: 100% (7/7), 1.94 KiB | 1.94 MiB/s, done. Total 7 (delta 5), reused 0 (delta 0), pack-reused 0 (from 0) To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-53-greg -> issue-53-greg branch 'issue-53-greg' set up to track 'origin/issue-53-greg'.
{"head": "issue-53-greg", "base": "main", "title": "Fix excessive vertical space in job view blocks (#53)", "body": "## Problem\n\nContent/reasoning blocks in the job view took up far more vertical space than their text warranted. The reported HTML:\n\n```html\n<div class=\"block block-content\"><div class=\"block-body\">\n\nNow the remaining tool callers:\n\n\n\n</div></div>\n```\n\n## Root cause\n\nThe guess in the issue was half right: `white-space: pre-wrap` on `.block-body` is what *makes* the newlines visible, but the root cause is that the model's output routinely starts/ends with newlines (here: one leading, three trailing). That text is persisted raw by `streamHooks` and rendered verbatim by `{{.Text}}`, so each newline becomes a visible blank line at `line-height: 1.6`.\n\n## Fix (display layer)\n\n- **`internal/web/web.go` β `buildBlocks`**: trim leading/trailing whitespace from `reasoning`/`content` block text before rendering; drop blocks that are nothing but whitespace. Internal newlines are preserved. Fixing this at the display layer (rather than rewriting stored rows) means **all existing jobs** render correctly immediately. The store keeps the raw model output as a faithful record.\n- **`internal/web/templates.go` β live SSE script**: trim the accumulated text on `reasoning_end`/`content_end` so the live view matches the final static view.\n\n## Tests\n\n- New `TestJobDetailTrimsBlockWhitespace` covers the exact case from the issue (leading/trailing newlines trimmed), whitespace-only blocks being dropped, and internal newlines being preserved.\n- Full suite passes: `go build ./... && go test ./...`"}
Pull request opened
{"body": "Investigated β your hunch was half right. `white-space: pre-wrap` on `.block-body` is what *makes* the newlines visible, but the root cause is that the model's output routinely starts/ends with newlines (the snippet you pasted has one leading and three trailing). That text is persisted raw and rendered verbatim by `{{.Text}}`, so each newline becomes a visible blank line at `line-height: 1.6`.\n\nFix (PR from `issue-53-greg`):\n\n- **`buildBlocks` (internal/web/web.go)** β trims leading/trailing whitespace from reasoning/content blocks before rendering, and drops blocks that are nothing but whitespace. Internal newlines are preserved. Done at the display layer (rather than rewriting stored rows) so **all existing jobs** render correctly immediately; the store keeps the raw model output as a faithful record.\n- **Live SSE view (templates.go)** β trims the accumulated text when a block ends, so the live view matches the final static view.\n\nAdded a regression test covering your exact case plus whitespace-only blocks and internal-newline preservation. Full test suite passes."}
Comment posted