Improve the transcript: tool grouping, report auto-ack, context meter, spinner, Enter to send
This commit is contained in:
+250
-33
@@ -5,14 +5,102 @@ import { currentSession, findTask, onEach, state } from "./state.js";
|
||||
|
||||
const models = ["", "opus", "sonnet", "haiku"];
|
||||
|
||||
type Call = { name: string; target: string };
|
||||
|
||||
type Run = {
|
||||
first: HTMLElement;
|
||||
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
|
||||
calls: Call[];
|
||||
};
|
||||
|
||||
type Ctx = {
|
||||
tools: Map<string, HTMLElement>;
|
||||
run: Run | undefined;
|
||||
root: string;
|
||||
watchReport: (el: HTMLElement, itemId: string) => void;
|
||||
};
|
||||
|
||||
export function mountTranscript(root: HTMLElement): () => void {
|
||||
const head = h("header", { class: "task-header" });
|
||||
const transcript = h("div", { class: "transcript" });
|
||||
const composer = h("div", { class: "composer" });
|
||||
let tools = new Map<string, HTMLElement>();
|
||||
|
||||
const visibleReports = new Map<Element, string>();
|
||||
const acked = new Set<string>();
|
||||
const ackVisible = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
const ids = [...new Set(visibleReports.values())].filter((id) => !acked.has(id) && state.inbox.some((i) => i.id === id));
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const id of ids) {
|
||||
acked.add(id);
|
||||
}
|
||||
post("/api/inbox/responses", { body: { responses: ids.map((id) => ({ item_id: id, type: "dismiss" as const })) } }).catch((err) => {
|
||||
for (const id of ids) {
|
||||
acked.delete(id);
|
||||
}
|
||||
console.error("acknowledge reports", err);
|
||||
});
|
||||
};
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
const id = (e.target as HTMLElement).dataset["inboxItem"];
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
if (e.isIntersecting) {
|
||||
visibleReports.set(e.target, id);
|
||||
} else {
|
||||
visibleReports.delete(e.target);
|
||||
}
|
||||
}
|
||||
ackVisible();
|
||||
},
|
||||
{ root: transcript, threshold: 0.5 },
|
||||
);
|
||||
|
||||
const newCtx = (): Ctx => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
return {
|
||||
tools: new Map(),
|
||||
run: undefined,
|
||||
root: task?.worktree_path ?? "",
|
||||
watchReport: (el, itemId) => {
|
||||
el.dataset["inboxItem"] = itemId;
|
||||
observer.observe(el);
|
||||
},
|
||||
};
|
||||
};
|
||||
let ctx = newCtx();
|
||||
let context = new Map<string, { used: number; window: number; model: string }>();
|
||||
|
||||
const spinner = h("div", { class: "spinner", hidden: true });
|
||||
const setRunning = (running: boolean) => {
|
||||
transcript.classList.toggle("running", running);
|
||||
if (running === !spinner.hidden) {
|
||||
return;
|
||||
}
|
||||
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
|
||||
spinner.hidden = !running;
|
||||
if (pinned) {
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
}
|
||||
};
|
||||
const put = (el: HTMLElement) => {
|
||||
if (spinner.parentNode === transcript) {
|
||||
transcript.insertBefore(el, spinner);
|
||||
} else {
|
||||
transcript.append(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderHead = () => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
setRunning(task !== undefined && currentSession(task.id)?.state === "running");
|
||||
if (!task) {
|
||||
replace(head);
|
||||
return;
|
||||
@@ -32,8 +120,8 @@ export function mountTranscript(root: HTMLElement): () => void {
|
||||
};
|
||||
replace(
|
||||
head,
|
||||
h("div", { class: "meta" }, h("code", {}, task.branch), h("code", { class: "path" }, task.worktree_path)),
|
||||
h("div", { class: "head-actions" }, sess && h("span", { class: `state ${sess.state}` }, sess.state), complete),
|
||||
h("div", { class: "meta" }, h("code", {}, task.branch)),
|
||||
h("div", { class: "head-actions" }, sess && contextMeter(context.get(sess.id)), complete),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,16 +136,63 @@ export function mountTranscript(root: HTMLElement): () => void {
|
||||
replace(composer, startForm(task.id));
|
||||
return;
|
||||
}
|
||||
replace(composer, messageForm(sess.id, sess.state));
|
||||
replace(composer, messageForm(task.id, sess.id, sess.state));
|
||||
};
|
||||
|
||||
const addToRun = (el: HTMLElement, call: Call | undefined) => {
|
||||
const run = ctx.run;
|
||||
if (!run) {
|
||||
ctx.run = { first: el, group: undefined, calls: call ? [call] : [] };
|
||||
put(el);
|
||||
return;
|
||||
}
|
||||
if (call) {
|
||||
run.calls.push(call);
|
||||
}
|
||||
if (!run.group) {
|
||||
const summary = h("summary");
|
||||
const body = h("div", { class: "tool-group-body" });
|
||||
const group = h("details", { class: "tool-group" }, summary, body);
|
||||
run.first.replaceWith(group);
|
||||
body.append(run.first);
|
||||
if (run.first.classList.contains("errored")) {
|
||||
group.classList.add("errored");
|
||||
}
|
||||
run.group = { el: group, summary, body };
|
||||
}
|
||||
run.group.body.append(el);
|
||||
replace(run.group.summary, runSummary(run.calls));
|
||||
};
|
||||
|
||||
const appendEvents = (events: ByreEvent[]) => {
|
||||
const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
|
||||
let contextChanged = false;
|
||||
for (const ev of events) {
|
||||
const el = renderEvent(ev, tools);
|
||||
if (el) {
|
||||
transcript.append(el);
|
||||
const p = ev.payload as Record<string, unknown>;
|
||||
if (ev.kind === "session.context" && ev.agent_session_id) {
|
||||
const prev = context.get(ev.agent_session_id);
|
||||
const window = Number(p["window"]) || prev?.window || 0;
|
||||
context.set(ev.agent_session_id, { used: Number(p["used"]), window, model: String(p["model"] ?? "") });
|
||||
contextChanged = true;
|
||||
continue;
|
||||
}
|
||||
if (ev.kind === "agent.tool_call") {
|
||||
addToRun(renderToolCall(p, ctx), { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
|
||||
continue;
|
||||
}
|
||||
const el = renderEvent(ev, ctx);
|
||||
if (!el) {
|
||||
continue;
|
||||
}
|
||||
if (ev.kind === "agent.thinking") {
|
||||
addToRun(el, undefined);
|
||||
continue;
|
||||
}
|
||||
ctx.run = undefined;
|
||||
put(el);
|
||||
}
|
||||
if (contextChanged) {
|
||||
renderHead();
|
||||
}
|
||||
if (pinned) {
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
@@ -65,14 +200,17 @@ export function mountTranscript(root: HTMLElement): () => void {
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
tools = new Map();
|
||||
observer.disconnect();
|
||||
visibleReports.clear();
|
||||
ctx = newCtx();
|
||||
context = new Map();
|
||||
renderHead();
|
||||
renderComposer();
|
||||
if (!state.selectedTaskId) {
|
||||
replace(transcript, h("div", { class: "empty" }, "No task selected. Pick one with ⌘K or from the Tasks tab."));
|
||||
return;
|
||||
}
|
||||
replace(transcript);
|
||||
replace(transcript, spinner);
|
||||
appendEvents(state.transcript);
|
||||
};
|
||||
|
||||
@@ -89,7 +227,9 @@ export function mountTranscript(root: HTMLElement): () => void {
|
||||
}
|
||||
}),
|
||||
onEach(["transcript"], (fresh) => appendEvents(fresh as ByreEvent[])),
|
||||
onEach(["inbox"], ackVisible),
|
||||
];
|
||||
document.addEventListener("visibilitychange", ackVisible);
|
||||
|
||||
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
|
||||
renderAll();
|
||||
@@ -97,27 +237,46 @@ export function mountTranscript(root: HTMLElement): () => void {
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
document.removeEventListener("visibilitychange", ackVisible);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
const composerDrafts = new Map<string, string>();
|
||||
|
||||
function keepDraft(input: HTMLTextAreaElement, key: string): () => void {
|
||||
input.value = composerDrafts.get(key) ?? "";
|
||||
input.addEventListener("input", () => {
|
||||
if (input.value) {
|
||||
composerDrafts.set(key, input.value);
|
||||
} else {
|
||||
composerDrafts.delete(key);
|
||||
}
|
||||
});
|
||||
return () => composerDrafts.delete(key);
|
||||
}
|
||||
|
||||
function startForm(taskId: string): HTMLElement {
|
||||
const prompt = h("textarea", { placeholder: "What should the agent do?", rows: 4, required: true });
|
||||
const clearDraft = keepDraft(prompt, `start:${taskId}`);
|
||||
const model = h("select", {}, ...models.map((m) => h("option", { value: m }, m || "default model")));
|
||||
const form = h("form", { class: "compose" }, prompt, h("div", { class: "compose-actions" }, model, h("button", { type: "submit" }, "Start agent")));
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await post("/api/tasks/{taskId}/sessions", { path: { taskId }, body: { prompt: prompt.value, model: model.value || undefined } });
|
||||
clearDraft();
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
};
|
||||
submitOnModEnter(prompt, form);
|
||||
submitOnEnter(prompt, form);
|
||||
return form;
|
||||
}
|
||||
|
||||
function messageForm(sessionId: string, sessionState: string): HTMLElement {
|
||||
function messageForm(taskId: string, sessionId: string, sessionState: string): HTMLElement {
|
||||
const text = h("textarea", { placeholder: sessionState === "idle" ? "Message the agent" : "Wait for the turn to end, or interrupt it", rows: 3 });
|
||||
const clearDraft = keepDraft(text, `message:${taskId}`);
|
||||
const send = h("button", { type: "submit", disabled: sessionState !== "idle" }, "Send");
|
||||
const interrupt = h(
|
||||
"button",
|
||||
@@ -141,29 +300,81 @@ function messageForm(sessionId: string, sessionState: string): HTMLElement {
|
||||
try {
|
||||
await post("/api/sessions/{sessionId}/messages", { path: { sessionId }, body: { text: text.value } });
|
||||
text.value = "";
|
||||
clearDraft();
|
||||
} catch (err) {
|
||||
alert(errorText(err));
|
||||
}
|
||||
};
|
||||
submitOnModEnter(text, form);
|
||||
submitOnEnter(text, form);
|
||||
return form;
|
||||
}
|
||||
|
||||
function submitOnModEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void {
|
||||
function submitOnEnter(input: HTMLTextAreaElement, form: HTMLFormElement): void {
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderEvent(ev: ByreEvent, tools: Map<string, HTMLElement>): HTMLElement | null {
|
||||
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);
|
||||
return h(
|
||||
"details",
|
||||
{ class: "tool" },
|
||||
h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))),
|
||||
h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function runSummary(calls: Call[]): HTMLElement {
|
||||
const counts = new Map<string, { call: Call; n: number }>();
|
||||
for (const c of calls) {
|
||||
const key = `${c.name}\0${c.target}`;
|
||||
const entry = counts.get(key);
|
||||
if (entry) {
|
||||
entry.n++;
|
||||
} else {
|
||||
counts.set(key, { call: c, n: 1 });
|
||||
}
|
||||
}
|
||||
if (counts.size === 0) {
|
||||
return h("span", { class: "tool-brief" }, "thinking");
|
||||
}
|
||||
const parts: (HTMLElement | string)[] = [];
|
||||
for (const { call, n } of counts.values()) {
|
||||
if (parts.length > 0) {
|
||||
parts.push(", ");
|
||||
}
|
||||
parts.push(
|
||||
h("span", {}, h("span", { class: "tool-name" }, call.name), call.target ? h("span", { class: "tool-brief" }, " ", call.target) : null, n > 1 ? h("span", { class: "tool-count" }, ` ×${n}`) : null),
|
||||
);
|
||||
}
|
||||
return h("span", {}, ...parts);
|
||||
}
|
||||
|
||||
function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
|
||||
const p = ev.payload as Record<string, unknown>;
|
||||
const when = h("time", { dateTime: ev.created_at, title: ev.created_at }, ago(ev.created_at));
|
||||
|
||||
switch (ev.kind) {
|
||||
case "user.message":
|
||||
if (ev.actor === "byre") {
|
||||
return h("div", { class: "note" }, String(p["text"]), " ", when);
|
||||
}
|
||||
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), markdown(String(p["text"]), "text md"));
|
||||
|
||||
case "agent.message":
|
||||
@@ -172,20 +383,8 @@ function renderEvent(ev: ByreEvent, tools: Map<string, HTMLElement>): HTMLElemen
|
||||
case "agent.thinking":
|
||||
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md"));
|
||||
|
||||
case "agent.tool_call": {
|
||||
const result = h("div", { class: "tool-result pending" }, "running…");
|
||||
tools.set(String(p["id"]), result);
|
||||
return h(
|
||||
"details",
|
||||
{ class: "tool" },
|
||||
h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"]))),
|
||||
h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
case "agent.tool_result": {
|
||||
const target = tools.get(String(p["tool_use_id"]));
|
||||
const target = ctx.tools.get(String(p["tool_use_id"]));
|
||||
const body = h("pre", {}, resultText(p["content"]));
|
||||
if (!target) {
|
||||
return h("div", { class: "tool-result orphan" }, body);
|
||||
@@ -193,13 +392,19 @@ function renderEvent(ev: ByreEvent, tools: Map<string, HTMLElement>): HTMLElemen
|
||||
target.className = p["is_error"] ? "tool-result error" : "tool-result";
|
||||
replace(target, body);
|
||||
if (p["is_error"]) {
|
||||
target.closest("details")?.classList.add("errored");
|
||||
target.closest("details.tool")?.classList.add("errored");
|
||||
target.closest("details.tool-group")?.classList.add("errored");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "agent.report":
|
||||
return reportCard(p);
|
||||
case "agent.report": {
|
||||
const card = reportCard(p);
|
||||
if (typeof p["inbox_item_id"] === "string" && p["inbox_item_id"]) {
|
||||
ctx.watchReport(card, p["inbox_item_id"]);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
case "turn.ended":
|
||||
return h("div", { class: `turn-end ${p["is_error"] ? "error" : ""}` }, p["is_error"] ? `turn ended: ${String(p["subtype"]).replaceAll("_", " ")}` : "");
|
||||
@@ -250,16 +455,28 @@ export function reportCard(p: Record<string, unknown>): HTMLElement {
|
||||
);
|
||||
}
|
||||
|
||||
function toolBrief(input: unknown): string {
|
||||
function toolBrief(input: unknown, root: string): string {
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return "";
|
||||
}
|
||||
const i = input as Record<string, unknown>;
|
||||
const brief = i["command"] ?? i["file_path"] ?? i["pattern"] ?? i["url"] ?? i["question"] ?? i["description"] ?? "";
|
||||
const text = String(brief);
|
||||
const text = relative(String(brief), root);
|
||||
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
||||
}
|
||||
|
||||
function toolTarget(input: unknown, root: string): string {
|
||||
if (typeof input !== "object" || input === null) {
|
||||
return "";
|
||||
}
|
||||
const path = (input as Record<string, unknown>)["file_path"];
|
||||
return typeof path === "string" ? relative(path, root) : "";
|
||||
}
|
||||
|
||||
function relative(path: string, root: string): string {
|
||||
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
||||
}
|
||||
|
||||
function resultText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
|
||||
Reference in New Issue
Block a user