Load the lead's scrollback 50 turns at a time
This commit is contained in:
@@ -54,6 +54,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/me/lead/events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description The calling user's lead events, oldest first, a page of whole turns: those after the turns+1'th turn.ended before the before seq (or before the latest event when before is absent). Page back by passing the first event's seq as before. */
|
||||
get: operations["listLeadEvents"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/me/lead/messages": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1374,6 +1391,11 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
EventPage: {
|
||||
events: components["schemas"]["Event"][];
|
||||
/** @description Whether there are earlier events than this page. */
|
||||
more: boolean;
|
||||
};
|
||||
Focus: {
|
||||
/** @description The project the right pane shows; the task's project when task_id is set */
|
||||
project_id?: string;
|
||||
@@ -1595,6 +1617,29 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
listLeadEvents: {
|
||||
parameters: {
|
||||
query?: {
|
||||
before?: number;
|
||||
turns?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Up to turns turns (default 50), plus any turn still running when before is absent. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["EventPage"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
messageLead: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ async function main(): Promise<void> {
|
||||
if (/^#\/(task|project)\/\w+(\/\w+)?$/.test(linked)) {
|
||||
location.hash = linked;
|
||||
}
|
||||
startLeadStream();
|
||||
void startLeadStream();
|
||||
|
||||
replace(document.body, banner, h("div", { class: "panes" }, leadPane(), taskPane()));
|
||||
bindShortcuts();
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { post } from "./api.js";
|
||||
import { h } from "./dom.js";
|
||||
import { currentLead, leadLoading, state } from "./state.js";
|
||||
import { currentLead, leadHasOlder, leadLoading, loadOlderLead, state } from "./state.js";
|
||||
import { compactButton, mountChat, type ChatSource, type Head } from "./task.js";
|
||||
|
||||
const leadSource: ChatSource = {
|
||||
@@ -15,6 +15,7 @@ const leadSource: ChatSource = {
|
||||
transcriptTopic: "lead.transcript",
|
||||
streamTopic: "lead.stream",
|
||||
readyTopic: "lead.ready",
|
||||
older: { has: leadHasOlder, load: loadOlderLead },
|
||||
empty: "",
|
||||
canSend: () => true,
|
||||
placeholder: "Message your lead. Paste or drop images to attach them.",
|
||||
|
||||
+34
-3
@@ -1,4 +1,4 @@
|
||||
import { get, put, subscribe, type AgentSession, type ByreEvent, type Focus, type InboxItem, type Project, type Snapshot, type Task, type TaskTab, type User } from "./api.js";
|
||||
import { get, put, subscribe, type AgentSession, type ByreEvent, type Focus, type InboxItem, type Project, type Schemas, type Snapshot, type Task, type TaskTab, type User } from "./api.js";
|
||||
import { notifyInbox, notifyTurn } from "./notifications.js";
|
||||
|
||||
export type Topic =
|
||||
@@ -441,8 +441,39 @@ export function selectTask(taskId: string | undefined): void {
|
||||
notify("selection");
|
||||
}
|
||||
|
||||
export function startLeadStream(): void {
|
||||
subscribe(0, { lead: true }, leadFeed.push, () => undefined, () => leadFeed.load([]));
|
||||
const leadPage = 50;
|
||||
let leadMore = false;
|
||||
|
||||
async function fetchLeadTail(): Promise<Schemas["EventPage"]> {
|
||||
for (;;) {
|
||||
try {
|
||||
return await get("/api/me/lead/events", { query: { turns: leadPage } });
|
||||
} catch (err) {
|
||||
console.error("load lead history", err);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startLeadStream(): Promise<void> {
|
||||
const page = await fetchLeadTail();
|
||||
leadMore = page.more;
|
||||
subscribe(page.events.at(-1)?.seq ?? 0, { lead: true }, leadFeed.push, () => undefined, () => leadFeed.load(page.events));
|
||||
}
|
||||
|
||||
export function leadHasOlder(): boolean {
|
||||
return leadFeed.ready && leadMore;
|
||||
}
|
||||
|
||||
export async function loadOlderLead(): Promise<ByreEvent[]> {
|
||||
const first = leadFeed.events[0];
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
const page = await get("/api/me/lead/events", { query: { before: first.seq, turns: leadPage } });
|
||||
leadMore = page.more;
|
||||
leadFeed.events.unshift(...page.events);
|
||||
return page.events;
|
||||
}
|
||||
|
||||
export function handleEvent(ev: ByreEvent): void {
|
||||
|
||||
+79
-6
@@ -24,6 +24,7 @@ export type ChatSource = {
|
||||
transcriptTopic: Topic;
|
||||
streamTopic: Topic;
|
||||
readyTopic: Topic;
|
||||
older?: { has: () => boolean; load: () => Promise<ByreEvent[]> };
|
||||
empty: string;
|
||||
canSend: (sessionState: string) => boolean;
|
||||
placeholder: string;
|
||||
@@ -287,11 +288,17 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
|
||||
const scanner = h("div", { class: "scanner", hidden: true });
|
||||
const loading = h("div", { class: "empty" }, "loading…");
|
||||
const earlier = h("div");
|
||||
let sink: DocumentFragment | undefined;
|
||||
const setRunning = (running: boolean) => {
|
||||
transcript.classList.toggle("running", running);
|
||||
scanner.hidden = !running;
|
||||
};
|
||||
const putBefore = (el: HTMLElement, anchor: HTMLElement | undefined) => {
|
||||
if (sink) {
|
||||
sink.append(el);
|
||||
return;
|
||||
}
|
||||
if (anchor?.parentNode === transcript) {
|
||||
transcript.insertBefore(el, anchor);
|
||||
} else {
|
||||
@@ -483,9 +490,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
return handoff.summary;
|
||||
};
|
||||
|
||||
const appendEvents = (events: ByreEvent[]) => {
|
||||
const renderEvents = (events: ByreEvent[]): boolean => {
|
||||
let contextChanged = false;
|
||||
headImpl.onEvents?.(events);
|
||||
for (const ev of events) {
|
||||
const p = ev.payload as Record<string, unknown>;
|
||||
const parent = parentOf(p);
|
||||
@@ -500,12 +506,15 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
claudeTaskStatus(p, ctx);
|
||||
continue;
|
||||
}
|
||||
if ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message") {
|
||||
if (!sink && ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message")) {
|
||||
dropStream();
|
||||
}
|
||||
if (ev.kind === "user.message" && ev.actor !== "byre") {
|
||||
if (!sink && ev.kind === "user.message" && ev.actor !== "byre") {
|
||||
confirmPending(String(p["text"] ?? ""));
|
||||
}
|
||||
if (ev.kind === "session.context" && sink) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
@@ -590,6 +599,12 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
}
|
||||
put(el);
|
||||
}
|
||||
return contextChanged;
|
||||
};
|
||||
|
||||
const appendEvents = (events: ByreEvent[]) => {
|
||||
headImpl.onEvents?.(events);
|
||||
const contextChanged = renderEvents(events);
|
||||
syncRunning();
|
||||
if (contextChanged) {
|
||||
renderHead();
|
||||
@@ -603,6 +618,58 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
jump();
|
||||
};
|
||||
|
||||
const prependEvents = (events: ByreEvent[]) => {
|
||||
const live = ctx;
|
||||
const fromBottom = transcript.scrollHeight - transcript.scrollTop;
|
||||
const box = document.createDocumentFragment();
|
||||
ctx = newCtx();
|
||||
sink = box;
|
||||
renderEvents(events);
|
||||
sink = undefined;
|
||||
ctx = live;
|
||||
syncRunning();
|
||||
fillTodoLinks(box, models[0]!);
|
||||
earlier.after(box);
|
||||
transcript.scrollTop = transcript.scrollHeight - fromBottom;
|
||||
};
|
||||
|
||||
let disposed = false;
|
||||
let fetching = false;
|
||||
const loadOlder = async () => {
|
||||
const older = src.older;
|
||||
if (!older || fetching || !older.has()) {
|
||||
return;
|
||||
}
|
||||
fetching = true;
|
||||
replace(earlier, h("div", { class: "empty" }, "loading earlier…"));
|
||||
try {
|
||||
const events = await older.load();
|
||||
replace(earlier);
|
||||
if (!disposed) {
|
||||
prependEvents(events);
|
||||
}
|
||||
} finally {
|
||||
fetching = false;
|
||||
replace(earlier);
|
||||
}
|
||||
watchTop();
|
||||
};
|
||||
const top = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
loadOlder().catch((err: unknown) => console.error("load earlier events", err));
|
||||
}
|
||||
},
|
||||
{ root: transcript, rootMargin: "400px 0px 0px 0px" },
|
||||
);
|
||||
const watchTop = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
top.unobserve(earlier);
|
||||
top.observe(earlier);
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
stuck = true;
|
||||
dropStream();
|
||||
@@ -619,12 +686,13 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
replace(transcript, h("div", { class: "empty" }, src.empty), scanner);
|
||||
return;
|
||||
}
|
||||
replace(transcript, scanner);
|
||||
replace(transcript, earlier, scanner);
|
||||
if (src.loading()) {
|
||||
transcript.prepend(loading);
|
||||
}
|
||||
appendEvents(src.events());
|
||||
renderComposer();
|
||||
watchTop();
|
||||
};
|
||||
|
||||
const offs = [
|
||||
@@ -635,7 +703,10 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
}),
|
||||
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh as ByreEvent[])),
|
||||
onEach([src.streamTopic], (ev) => appendDelta(ev as ByreEvent)),
|
||||
onEach([src.readyTopic], () => loading.remove()),
|
||||
onEach([src.readyTopic], () => {
|
||||
loading.remove();
|
||||
watchTop();
|
||||
}),
|
||||
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0]!)),
|
||||
on("jump", jump),
|
||||
];
|
||||
@@ -646,6 +717,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
disposed = true;
|
||||
top.disconnect();
|
||||
headImpl.dispose?.();
|
||||
resizes.disconnect();
|
||||
mutations.disconnect();
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ export function todoLink(projectId: string, title: string, children: Node[]): HT
|
||||
return h("span", { class: "todo-link", data: { project: projectId, title } }, h("span", { class: "todo-title" }, ...children), " ", h("span", { class: "todo-action" }));
|
||||
}
|
||||
|
||||
export function fillTodoLinks(root: HTMLElement, model: string): void {
|
||||
export function fillTodoLinks(root: ParentNode, model: string): void {
|
||||
for (const el of root.querySelectorAll<HTMLElement>(".todo-link")) {
|
||||
fill(el, model);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user