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
+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 { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js";
@@ -58,6 +58,29 @@ export function mountTranscript(root) {
};
let ctx = newCtx();
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 setRunning = (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 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 () => {
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 } });
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 focusStart = false;
@@ -230,6 +253,10 @@ export function mountTranscript(root) {
let contextChanged = false;
for (const ev of events) {
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") {
dropStream();
}
@@ -285,6 +312,8 @@ export function mountTranscript(root) {
ctx = newCtx();
context = new Map();
focusStart = true;
clearTimeout(unmergedTimer);
void loadUnmerged();
renderHead();
if (!state.selectedTaskId) {
renderComposer();
@@ -319,6 +348,7 @@ export function mountTranscript(root) {
off();
}
document.removeEventListener("visibilitychange", ackVisible);
clearTimeout(unmergedTimer);
observer.disconnect();
dropStream();
};
@@ -515,9 +545,21 @@ function submitOnEnter(input, form) {
}
};
}
function compactButton(sessionId, sessionState) {
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");
function compactButton(sessionId, sessionState, c) {
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 () => {
if (!sessionId) {
return;
}
b.disabled = true;
try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
@@ -529,15 +571,6 @@ function compactButton(sessionId, sessionState) {
};
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) {
const result = h("div", { class: "tool-result pending" }, "running…");
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 { markdown } from "./markdown.js";
import { currentSession, findTask, onEach, state } from "./state.js";
@@ -81,6 +81,28 @@ export function mountTranscript(root: HTMLElement): () => void {
};
let ctx = newCtx();
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 setRunning = (running: boolean) => {
@@ -118,7 +140,7 @@ export function mountTranscript(root: HTMLElement): () => void {
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` },
{ 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 = () =>
@@ -143,7 +165,7 @@ export function mountTranscript(root: HTMLElement): () => void {
});
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 session-actions" }, compactButton(sess?.id, sess?.state, sess && context.get(sess.id)), mergeIn),
h("div", { class: "head-actions" }, merge, discard),
);
};
@@ -274,6 +296,10 @@ export function mountTranscript(root: HTMLElement): () => void {
let contextChanged = false;
for (const ev of events) {
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") {
dropStream();
}
@@ -330,6 +356,8 @@ export function mountTranscript(root: HTMLElement): () => void {
ctx = newCtx();
context = new Map();
focusStart = true;
clearTimeout(unmergedTimer);
void loadUnmerged();
renderHead();
if (!state.selectedTaskId) {
renderComposer();
@@ -366,6 +394,7 @@ export function mountTranscript(root: HTMLElement): () => void {
off();
}
document.removeEventListener("visibilitychange", ackVisible);
clearTimeout(unmergedTimer);
observer.disconnect();
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(
"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",
);
b.onclick = async () => {
if (!sessionId) {
return;
}
b.disabled = true;
try {
await post("/api/sessions/{sessionId}/compact", { path: { sessionId } });
@@ -605,16 +646,6 @@ function compactButton(sessionId: string, sessionState: string): HTMLElement {
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 {
const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result);
+9 -11
View File
@@ -451,21 +451,19 @@ textarea {
margin-right: auto;
}
.context-meter {
font-size: 11px;
button.compact {
--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 {
color: var(--ok);
button.compact.warn {
--tint: color-mix(in srgb, var(--warn) 25%, var(--raised));
border-color: var(--warn);
}
.context-meter.warn {
color: var(--warn);
}
.context-meter.danger {
color: var(--danger);
font-weight: 700;
button.compact.danger {
--tint: color-mix(in srgb, var(--danger) 25%, var(--raised));
border-color: var(--danger);
}
.transcript {