Context fill on Compact, gate Merge on changes, fix its tooltip

This commit is contained in:
Ian Gulliver
2026-10-14 23:38:17 -07:00
parent c7cfad88a1
commit 51ed0a3811
6 changed files with 107 additions and 44 deletions
+1
View File
@@ -7,6 +7,7 @@ These are requirements, not preferences. Changing one needs the owner's agreemen
- Deploy before handing off. Any change that affects the running server or the web UI is deployed to the service before you stop and report, so the owner can look at it immediately: `go generate ./web` if web sources changed, then `go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/run.fc.byre`, always as that one command, never `go install` on its own. Then confirm it came up: the tail of `~/Library/Logs/byre.log`, and `GET /api/admin/status` through the `tailscale serve` URL (`tailscale serve status` shows it). Report what you deployed and what you checked. - Deploy before handing off. Any change that affects the running server or the web UI is deployed to the service before you stop and report, so the owner can look at it immediately: `go generate ./web` if web sources changed, then `go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/run.fc.byre`, always as that one command, never `go install` on its own. Then confirm it came up: the tail of `~/Library/Logs/byre.log`, and `GET /api/admin/status` through the `tailscale serve` URL (`tailscale serve status` shows it). Report what you deployed and what you checked.
- `go install` does not restart the service. If byre restarts after your install without your kickstart, another agent deployed its own branch, and the running binary is theirs, not yours. - `go install` does not restart the service. If byre restarts after your install without your kickstart, another agent deployed its own branch, and the running binary is theirs, not yours.
- You run inside byre, so the kickstart kills your own turn partway through the command. That is expected, not a reason to skip it. Make the deploy the last action of the turn. You resume with a "byre restarted while you were mid-turn" message; do the checks then, and confirm the service started after your kickstart. - You run inside byre, so the kickstart kills your own turn partway through the command. That is expected, not a reason to skip it. Make the deploy the last action of the turn. You resume with a "byre restarted while you were mid-turn" message; do the checks then, and confirm the service started after your kickstart.
- The restart can cut off the record of the last tool call before the kickstart, or the kickstart itself, so you may resume with no memory of having deployed. Before concluding that another agent restarted byre, compare the service's start time (`started_at` in `/api/admin/status`, or the last `listening` line in the log) with your most recent `go install`: a restart right after your own install is most likely your kickstart.
- `docs/operations.md` describes the layout, data, service and APIs; keep it current with every change. - `docs/operations.md` describes the layout, data, service and APIs; keep it current with every change.
## Web UI layout ## Web UI layout
+3 -3
View File
@@ -20,7 +20,7 @@ Schema changes are the `migrations` slice in `internal/store/store.go`: named en
## Closing a task ## Closing a task
A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a one-line confirmation and are disabled while the task's session is `running` or `queued`. A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a one-line confirmation and are disabled while the task's session is `running` or `queued`. Merge is also disabled until `unmerged` reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end.
`POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running` or `queued`. `POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running` or `queued`.
@@ -63,7 +63,7 @@ curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' ht
Images: `POST /api/blobs` takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB, `maxBlobBytes` in `internal/server/blobs.go`) and returns its id; `GET /api/blobs/{id}` serves it. A message or session prompt may carry `images`, a list of blob ids (unknown ids are a 400); a message may then have empty text. byre reads each blob when the turn is sent and passes it to Claude as a base64 `image` content block before the text (`claude.Session.Send`). The `user.message` event records the ids as `images`, and a queued turn keeps them in `turn_queue.images`. In the composer, pasting or dropping an image adds a thumbnail with a × to remove it; images upload when the message is sent, and the transcript shows them on the user's message, each linking to the full image. Images: `POST /api/blobs` takes a raw image body (type sniffed from the bytes, PNG, JPEG, GIF or WebP only, at most 5 MiB, `maxBlobBytes` in `internal/server/blobs.go`) and returns its id; `GET /api/blobs/{id}` serves it. A message or session prompt may carry `images`, a list of blob ids (unknown ids are a 400); a message may then have empty text. byre reads each blob when the turn is sent and passes it to Claude as a base64 `image` content block before the text (`claude.Session.Send`). The `user.message` event records the ids as `images`, and a queued turn keeps them in `turn_queue.images`. In the composer, pasting or dropping an image adds a thumbnail with a × to remove it; images upload when the message is sent, and the transcript shows them on the user's message, each linking to the full image.
Compaction is vendor-agnostic: `POST /api/sessions/{id}/compact` (the transcript's **Compact** button, enabled while the session is idle) sends the agent `handoffPrompt` (`internal/agents/compact.go`) as a `byre` message, asking for a handoff covering the goal, decisions and why, current state, dead ends, open questions and files. That turn files no inbox summary and isn't bounced for questions. When it ends normally, its `turn.ended` carries `handoff: true`, the session is completed, and a new session on the same task and model starts with `handoffPreamble` plus the handoff as its first message (actor `byre`). The context meter tracks the new session. Once the handoff turn ends, the transcript folds the request and everything the agent did in that turn into one collapsed note. If byre restarts during the handoff turn, the continuation is lost and the old session simply goes idle. Compaction is vendor-agnostic: `POST /api/sessions/{id}/compact` (the transcript's **Compact** button, always shown and enabled only while the task has an idle session) sends the agent `handoffPrompt` (`internal/agents/compact.go`) as a `byre` message, asking for a handoff covering the goal, decisions and why, current state, dead ends, open questions and files. That turn files no inbox summary and isn't bounced for questions. When it ends normally, its `turn.ended` carries `handoff: true`, the session is completed, and a new session on the same task and model starts with `handoffPreamble` plus the handoff as its first message (actor `byre`). The context meter tracks the new session. Once the handoff turn ends, the transcript folds the request and everything the agent did in that turn into one collapsed note. If byre restarts during the handoff turn, the continuation is lost and the old session simply goes idle.
Agents spawn parallel work with the `create_task` MCP tool (`internal/agents/tasks.go`): `title`, `prompt`, `base` and optional `model` (default: the caller's session model). It creates a task in the caller's project exactly as `POST /api/projects/{id}/tasks` does, and starts a session there with `prompt` as the first message (actor `agent`), created by the caller's session creator so commits use that user's git identity. `base` is the agent's choice: `default_branch` starts from the project's default branch, `this_task` from the caller's worktree `HEAD`, which leaves out uncommitted edits from the current turn. The tool returns the new task's id at once; the new session is independent of the caller. Agents spawn parallel work with the `create_task` MCP tool (`internal/agents/tasks.go`): `title`, `prompt`, `base` and optional `model` (default: the caller's session model). It creates a task in the caller's project exactly as `POST /api/projects/{id}/tasks` does, and starts a session there with `prompt` as the first message (actor `agent`), created by the caller's session creator so commits use that user's git identity. `base` is the agent's choice: `default_branch` starts from the project's default branch, `this_task` from the caller's worktree `HEAD`, which leaves out uncommitted edits from the current turn. The tool returns the new task's id at once; the new session is independent of the caller.
@@ -94,7 +94,7 @@ Agent health is `internal/agents/health.go`:
The Admin view (a pane tab, also opened in the right pane by the navbar's **Admin** button) shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); running turns against each cap, each running turn with the time since its process's last event and `stuck` or `exited` when it is; queued turns in order, both linking to their tasks; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created` or `admin.health` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`. The Admin view (a pane tab, also opened in the right pane by the navbar's **Admin** button) shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); running turns against each cap, each running turn with the time since its process's last event and `stuck` or `exited` when it is; queued turns in order, both linking to their tasks; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created` or `admin.health` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `inbox_item_id` when its final message was filed as a `report` item. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header shows the latest one as a percentage of the window, coloured green, amber from 70%, red from 90%. Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `inbox_item_id` when its final message was filed as a `report` item. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
Messages byre itself sends an agent (actor `byre`, such as the restart notice) show as a muted note, not a user bubble. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the `.spinner` mask over an accent background, and the `braille` keyframes in `web/style.css` step the mask position through them. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer is the same whether or not the task has a live session: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a "sending…" bubble, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. Messages byre itself sends an agent (actor `byre`, such as the restart notice) show as a muted note, not a user bubble. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the `.spinner` mask over an accent background, and the `braille` keyframes in `web/style.css` step the mask position through them. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer is the same whether or not the task has a live session: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a "sending…" bubble, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
+47 -14
View File
@@ -1,4 +1,4 @@
import { post } from "./api.js"; import { get, post } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js"; import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js"; import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js"; import { currentSession, findTask, onEach, state } from "./state.js";
@@ -58,6 +58,29 @@ export function mountTranscript(root) {
}; };
let ctx = newCtx(); let ctx = newCtx();
let context = new Map(); let context = new Map();
let unmerged;
let unmergedTimer;
const loadUnmerged = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
return;
}
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId } });
if (state.selectedTaskId !== taskId) {
return;
}
const has = u.commits > 0 || u.files > 0;
if (unmerged?.taskId === taskId && unmerged.has === has) {
return;
}
unmerged = { taskId, has };
renderHead();
}
catch (err) {
console.error("load unmerged changes", err);
}
};
const spinner = h("div", { class: "spinner", hidden: true }); const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running) => { const setRunning = (running) => {
transcript.classList.toggle("running", running); transcript.classList.toggle("running", running);
@@ -92,7 +115,7 @@ export function mountTranscript(root) {
} }
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch"; const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const busy = sess?.state === "running" || sess?.state === "queued"; const busy = sess?.state === "running" || sess?.state === "queued";
const merge = h("button", { disabled: busy, title: `The agent merges ${target} in, then byre squashes into one commit on ${target}; nothing is pushed` }, "Merge"); const merge = h("button", { disabled: busy || unmerged?.taskId !== task.id || !unmerged.has, title: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin` }, "Merge");
merge.onclick = () => confirmThen(`Merge "${task.title}" into ${target}?`, "Merge", false, async () => { merge.onclick = () => confirmThen(`Merge "${task.title}" into ${target}?`, "Merge", false, async () => {
await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } }); await post("/api/tasks/{taskId}/merge", { path: { taskId: task.id } });
}); });
@@ -112,7 +135,7 @@ export function mountTranscript(root) {
await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } }); await post("/api/tasks/{taskId}/discard", { path: { taskId: task.id } });
location.hash = "#/"; location.hash = "#/";
}); });
replace(head, h("div", { class: "head-actions session-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), mergeIn), h("div", { class: "head-actions" }, merge, discard)); replace(head, h("div", { class: "head-actions session-actions" }, compactButton(sess?.id, sess?.state, sess && context.get(sess.id)), mergeIn), h("div", { class: "head-actions" }, merge, discard));
}; };
let composerModel = ""; let composerModel = "";
let focusStart = false; let focusStart = false;
@@ -230,6 +253,10 @@ export function mountTranscript(root) {
let contextChanged = false; let contextChanged = false;
for (const ev of events) { for (const ev of events) {
const p = ev.payload; const p = ev.payload;
if (ev.kind === "agent.tool_result" || ev.kind === "turn.ended") {
clearTimeout(unmergedTimer);
unmergedTimer = setTimeout(() => void loadUnmerged(), 1500);
}
if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") { if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream(); dropStream();
} }
@@ -285,6 +312,8 @@ export function mountTranscript(root) {
ctx = newCtx(); ctx = newCtx();
context = new Map(); context = new Map();
focusStart = true; focusStart = true;
clearTimeout(unmergedTimer);
void loadUnmerged();
renderHead(); renderHead();
if (!state.selectedTaskId) { if (!state.selectedTaskId) {
renderComposer(); renderComposer();
@@ -319,6 +348,7 @@ export function mountTranscript(root) {
off(); off();
} }
document.removeEventListener("visibilitychange", ackVisible); document.removeEventListener("visibilitychange", ackVisible);
clearTimeout(unmergedTimer);
observer.disconnect(); observer.disconnect();
dropStream(); dropStream();
}; };
@@ -515,9 +545,21 @@ function submitOnEnter(input, form) {
} }
}; };
} }
function compactButton(sessionId, sessionState) { function compactButton(sessionId, sessionState, c) {
const b = h("button", { type: "button", disabled: sessionState !== "idle", title: "Ask the agent for a handoff, then continue in a new session with a fresh context" }, "Compact"); const pct = c && c.window > 0 ? Math.min(100, (c.used / c.window) * 100) : 0;
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const usage = c && c.window > 0 ? `\ncontext: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}` : "";
const b = h("button", {
type: "button",
class: `compact ${level}`,
style: `--fill: ${pct}%`,
disabled: !sessionId || sessionState !== "idle",
title: `Ask the agent for a handoff, then continue in a new session with a fresh context${usage}`,
}, "Compact");
b.onclick = async () => { b.onclick = async () => {
if (!sessionId) {
return;
}
b.disabled = true; b.disabled = true;
try { try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } }); await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
@@ -529,15 +571,6 @@ function compactButton(sessionId, sessionState) {
}; };
return b; return b;
} }
function contextMeter(c) {
if (!c || c.window <= 0) {
return null;
}
const pct = Math.min(100, (c.used / c.window) * 100);
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const title = `context: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}`;
return h("span", { class: `context-meter ${level}`, title }, `${Math.round(pct)}%`);
}
function renderToolCall(p, ctx) { function renderToolCall(p, ctx) {
const result = h("div", { class: "tool-result pending" }, "running…"); const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result); ctx.tools.set(String(p["id"]), result);
+1 -1
View File
File diff suppressed because one or more lines are too long
+46 -15
View File
@@ -1,4 +1,4 @@
import { post, type ByreEvent } from "./api.js"; import { get, post, type ByreEvent } from "./api.js";
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js"; import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
import { markdown } from "./markdown.js"; import { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js"; import { currentSession, findTask, onEach, state } from "./state.js";
@@ -81,6 +81,28 @@ export function mountTranscript(root: HTMLElement): () => void {
}; };
let ctx = newCtx(); let ctx = newCtx();
let context = new Map<string, { used: number; window: number; model: string }>(); let context = new Map<string, { used: number; window: number; model: string }>();
let unmerged: { taskId: string; has: boolean } | undefined;
let unmergedTimer: number | undefined;
const loadUnmerged = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
return;
}
try {
const u = await get("/api/tasks/{taskId}/unmerged", { path: { taskId } });
if (state.selectedTaskId !== taskId) {
return;
}
const has = u.commits > 0 || u.files > 0;
if (unmerged?.taskId === taskId && unmerged.has === has) {
return;
}
unmerged = { taskId, has };
renderHead();
} catch (err) {
console.error("load unmerged changes", err);
}
};
const spinner = h("div", { class: "spinner", hidden: true }); const spinner = h("div", { class: "spinner", hidden: true });
const setRunning = (running: boolean) => { const setRunning = (running: boolean) => {
@@ -118,7 +140,7 @@ export function mountTranscript(root: HTMLElement): () => void {
const busy = sess?.state === "running" || sess?.state === "queued"; const busy = sess?.state === "running" || sess?.state === "queued";
const merge = h( const merge = h(
"button", "button",
{ disabled: busy, title: `The agent merges ${target} in, then byre squashes into one commit on ${target}; nothing is pushed` }, { disabled: busy || unmerged?.taskId !== task.id || !unmerged.has, title: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin` },
"Merge", "Merge",
); );
merge.onclick = () => merge.onclick = () =>
@@ -143,7 +165,7 @@ export function mountTranscript(root: HTMLElement): () => void {
}); });
replace( replace(
head, head,
h("div", { class: "head-actions session-actions" }, sess && contextMeter(context.get(sess.id)), sess && compactButton(sess.id, sess.state), mergeIn), h("div", { class: "head-actions session-actions" }, compactButton(sess?.id, sess?.state, sess && context.get(sess.id)), mergeIn),
h("div", { class: "head-actions" }, merge, discard), h("div", { class: "head-actions" }, merge, discard),
); );
}; };
@@ -274,6 +296,10 @@ export function mountTranscript(root: HTMLElement): () => void {
let contextChanged = false; let contextChanged = false;
for (const ev of events) { for (const ev of events) {
const p = ev.payload as Record<string, unknown>; const p = ev.payload as Record<string, unknown>;
if (ev.kind === "agent.tool_result" || ev.kind === "turn.ended") {
clearTimeout(unmergedTimer);
unmergedTimer = setTimeout(() => void loadUnmerged(), 1500);
}
if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") { if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream(); dropStream();
} }
@@ -330,6 +356,8 @@ export function mountTranscript(root: HTMLElement): () => void {
ctx = newCtx(); ctx = newCtx();
context = new Map(); context = new Map();
focusStart = true; focusStart = true;
clearTimeout(unmergedTimer);
void loadUnmerged();
renderHead(); renderHead();
if (!state.selectedTaskId) { if (!state.selectedTaskId) {
renderComposer(); renderComposer();
@@ -366,6 +394,7 @@ export function mountTranscript(root: HTMLElement): () => void {
off(); off();
} }
document.removeEventListener("visibilitychange", ackVisible); document.removeEventListener("visibilitychange", ackVisible);
clearTimeout(unmergedTimer);
observer.disconnect(); observer.disconnect();
dropStream(); dropStream();
}; };
@@ -587,13 +616,25 @@ function submitOnEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void
}; };
} }
function compactButton(sessionId: string, sessionState: string): HTMLElement { function compactButton(sessionId: string | undefined, sessionState: string | undefined, c: { used: number; window: number; model: string } | undefined): HTMLElement {
const pct = c && c.window > 0 ? Math.min(100, (c.used / c.window) * 100) : 0;
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const usage = c && c.window > 0 ? `\ncontext: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}` : "";
const b = h( const b = h(
"button", "button",
{ type: "button", disabled: sessionState !== "idle", title: "Ask the agent for a handoff, then continue in a new session with a fresh context" }, {
type: "button",
class: `compact ${level}`,
style: `--fill: ${pct}%`,
disabled: !sessionId || sessionState !== "idle",
title: `Ask the agent for a handoff, then continue in a new session with a fresh context${usage}`,
},
"Compact", "Compact",
); );
b.onclick = async () => { b.onclick = async () => {
if (!sessionId) {
return;
}
b.disabled = true; b.disabled = true;
try { try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } }); await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
@@ -605,16 +646,6 @@ function compactButton(sessionId: string, sessionState: string): HTMLElement {
return b; return b;
} }
function contextMeter(c: { used: number; window: number; model: string } | undefined): HTMLElement | null {
if (!c || c.window <= 0) {
return null;
}
const pct = Math.min(100, (c.used / c.window) * 100);
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok";
const title = `context: ${c.used.toLocaleString()} of ${c.window.toLocaleString()} tokens${c.model ? ` (${c.model})` : ""}`;
return h("span", { class: `context-meter ${level}`, title }, `${Math.round(pct)}%`);
}
function renderToolCall(p: Record<string, unknown>, ctx: Ctx): HTMLElement { function renderToolCall(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
const result = h("div", { class: "tool-result pending" }, "running…"); const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result); ctx.tools.set(String(p["id"]), result);
+9 -11
View File
@@ -451,21 +451,19 @@ textarea {
margin-right: auto; margin-right: auto;
} }
.context-meter { button.compact {
font-size: 11px; --tint: color-mix(in srgb, var(--accent) 25%, var(--raised));
background: linear-gradient(to right, var(--tint) var(--fill), var(--raised) var(--fill));
} }
.context-meter.ok { button.compact.warn {
color: var(--ok); --tint: color-mix(in srgb, var(--warn) 25%, var(--raised));
border-color: var(--warn);
} }
.context-meter.warn { button.compact.danger {
color: var(--warn); --tint: color-mix(in srgb, var(--danger) 25%, var(--raised));
} border-color: var(--danger);
.context-meter.danger {
color: var(--danger);
font-weight: 700;
} }
.transcript { .transcript {