Keep active task transcripts in memory for instant task switching
This commit is contained in:
+4
-2
@@ -165,7 +165,7 @@ The left pane's **Status** tab shows, from the top: a Problems section only when
|
||||
|
||||
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`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall`, `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. 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 `summary: true` when its `result` is the turn's summary (see above). Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `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.
|
||||
|
||||
Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; a task's transcript stream starts from `after=0`, so opening a task sends all of it. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '<task-id>' GROUP BY kind ORDER BY 3 DESC"` one task's.
|
||||
Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the page loads every active task's whole history when it starts (see "Events"), so this is what a page load costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '<task-id>' GROUP BY kind ORDER BY 3 DESC"` one task's.
|
||||
|
||||
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2px dots, 5px apart along an 84px track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It takes no height (`.scanner` is a zero-height element kept as the transcript's last child, with a negative top margin that cancels the flex gap before it, and its `::before` draws the bar inside the bottom padding), so showing or hiding it never changes the transcript's scroll height or position. It is one element with one CSS animation: the `cylon` keyframes in `web/style.css` slide a gradient's `background-position` along the track, under a static repeating mask that cuts it into dots. 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. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. 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. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) stream inline with the main agent's, in arrival order, each marked `↳ <subagent_type>` with the description as its tooltip; its messages also get an accent rule down their left edge. A run of tool calls groups only calls from the same agent, so a call from another agent starts a new group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. 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 (see "Drafts" below), so it survives switching tabs or tasks and reloading the page. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: 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 bubble reading "queued…" while the session is running and "sending…" otherwise, 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.
|
||||
|
||||
@@ -303,6 +303,8 @@ A workflow is a named graph of stages, each a stage name, the stages it comes af
|
||||
|
||||
## Events
|
||||
|
||||
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. `?task_id=` limits the stream, replay and live, to one task's events (404 for an unknown task); `?lead=true` to the events of the caller's lead agent sessions. An event with an `owner` (a lead agent's events, a user's `focus.updated`) goes only to that user's streams; the others never see it. Unstored events (`seq` 0, currently only `agent.delta`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. The web UI (`subscribe` in `web/src/api.ts`) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
|
||||
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. `?task_id=` limits the stream, replay and live, to one task's events (404 for an unknown task); `?lead=true` to the events of the caller's lead agent sessions. An event with an `owner` (a lead agent's events, a user's `focus.updated`) goes only to that user's streams; the others never see it. Unstored events (`seq` 0, currently only `agent.delta`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. Once the replay is written, before anything live, the server sends an `event: ready`.
|
||||
|
||||
The page keeps every active task's events in memory, one `Feed` per task (`taskFeeds` in `web/src/state.ts`), so switching tasks makes no request. Each `loadTasks` creates a feed for any active task without one, which pages through `GET /api/tasks/{taskId}/events` (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the page's one global stream, which already carries every task's unowned events; a feed holds back what arrives while its history loads and drops the duplicates by `seq`. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged to the console and leaves the feed waiting for its history; the next `loadTasks` or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose `seq` is at or below the last one it kept, so an event in both is kept once. The lead's feed replays its own `?lead=true` stream and holds back what arrives until that stream's first `ready`. Either way the whole history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". The web UI (`subscribe` in `web/src/api.ts`) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
|
||||
|
||||
The web UI keeps three streams: one for all events from the page's load onward, which drives the task, session, inbox and lead session lists, the right pane's focus and the connection dot; one per selected task (`selectTask` in `web/src/state.ts`), opened with `?task_id=` and `after=0` so it replays the task's whole history and then stays live; and one for the lead (`startLeadStream`), opened with `?lead=true` and `after=0`. Each transcript is drawn only from its own stream, so a reconnect after a byre restart fills in both the history and anything missed. Selecting another task closes the old task stream. Events arriving together are drawn in one batch.
|
||||
|
||||
@@ -74,6 +74,10 @@ func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api
|
||||
}
|
||||
}
|
||||
|
||||
_, err := fmt.Fprint(w, "event: ready\ndata: ready\n\n")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if rc.Flush() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
Vendored
+5
-1
@@ -61,7 +61,7 @@ export function socket(path, opts) {
|
||||
}
|
||||
const staleAfterMs = 40_000;
|
||||
const retryAfterMs = 2_000;
|
||||
export function subscribe(after, filter, onEvent, onState) {
|
||||
export function subscribe(after, filter, onEvent, onState, onReady) {
|
||||
let last = after;
|
||||
let heard = Date.now();
|
||||
let source;
|
||||
@@ -92,6 +92,10 @@ export function subscribe(after, filter, onEvent, onState) {
|
||||
s.addEventListener("ping", () => {
|
||||
heard = Date.now();
|
||||
});
|
||||
s.addEventListener("ready", () => {
|
||||
heard = Date.now();
|
||||
onReady();
|
||||
});
|
||||
s.onmessage = (msg) => {
|
||||
heard = Date.now();
|
||||
const ev = JSON.parse(msg.data);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -72,7 +72,7 @@ async function main() {
|
||||
subscribe(status.last_event_seq, {}, handleEvent, (connected) => {
|
||||
state.connected = connected;
|
||||
notify("connection");
|
||||
});
|
||||
}, () => undefined);
|
||||
addEventListener("hashchange", route);
|
||||
route();
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+3
-1
@@ -1,10 +1,11 @@
|
||||
import { post } from "./api.js";
|
||||
import { h } from "./dom.js";
|
||||
import { currentLead, state } from "./state.js";
|
||||
import { currentLead, leadLoading, state } from "./state.js";
|
||||
import { compactButton, mountChat } from "./task.js";
|
||||
const leadSource = {
|
||||
key: () => "lead",
|
||||
events: () => state.leadTranscript,
|
||||
loading: leadLoading,
|
||||
session: currentLead,
|
||||
root: () => "",
|
||||
taskId: () => undefined,
|
||||
@@ -12,6 +13,7 @@ const leadSource = {
|
||||
refreshTopics: ["lead.sessions"],
|
||||
transcriptTopic: "lead.transcript",
|
||||
streamTopic: "lead.stream",
|
||||
readyTopic: "lead.ready",
|
||||
empty: "",
|
||||
canSend: () => true,
|
||||
placeholder: "Message your lead. Paste or drop images to attach them.",
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"lead.js","sourceRoot":"","sources":["../src/lead.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,CAAC,EAAE,MAAM,UAAU,CAAC;AAC7B,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,SAAS,EAA8B,MAAM,WAAW,CAAC;AAEjF,MAAM,UAAU,GAAe;IAC7B,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM;IACjB,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,cAAc;IAClC,OAAO,EAAE,WAAW;IACpB,IAAI,EAAE,GAAG,EAAE,CAAC,EAAE;IACd,MAAM,EAAE,GAAG,EAAE,CAAC,SAAS;IACvB,WAAW,EAAE,EAAE;IACf,aAAa,EAAE,CAAC,eAAe,CAAC;IAChC,eAAe,EAAE,iBAAiB;IAClC,WAAW,EAAE,aAAa;IAC1B,KAAK,EAAE,EAAE;IACT,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI;IACnB,WAAW,EAAE,yDAAyD;IACtE,IAAI,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,EAAE,EAAE;QAC7D,MAAM,IAAI,CAAC,uBAAuB,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACzG,CAAC;CACF,CAAC;AAEF,SAAS,QAAQ;IACf,OAAO;QACL,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YAChB,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;YAC3B,MAAM,IAAI,GAAG,IAAI,IAAI,IAAI,CAAC,KAAK,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;YACnE,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,8BAA8B,EAAE,EAAE,aAAa,CAAC,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7H,CAAC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAiB;IACzC,OAAO,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;AAC/C,CAAC"}
|
||||
{"version":3,"file":"lead.js","sourceRoot":"","sources":["../src/lead.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,CAAC,EAAE,MAAM,UAAU,CAAC;AAC7B,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,SAAS,EAA8B,MAAM,WAAW,CAAC;AAEjF,MAAM,UAAU,GAAe;IAC7B,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM;IACjB,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,cAAc;IAClC,OAAO,EAAE,WAAW;IACpB,OAAO,EAAE,WAAW;IACpB,IAAI,EAAE,GAAG,EAAE,CAAC,EAAE;IACd,MAAM,EAAE,GAAG,EAAE,CAAC,SAAS;IACvB,WAAW,EAAE,EAAE;IACf,aAAa,EAAE,CAAC,eAAe,CAAC;IAChC,eAAe,EAAE,iBAAiB;IAClC,WAAW,EAAE,aAAa;IAC1B,UAAU,EAAE,YAAY;IACxB,KAAK,EAAE,EAAE;IACT,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI;IACnB,WAAW,EAAE,yDAAyD;IACtE,IAAI,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,EAAE,EAAE;QAC7D,MAAM,IAAI,CAAC,uBAAuB,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACzG,CAAC;CACF,CAAC;AAEF,SAAS,QAAQ;IACf,OAAO;QACL,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YAChB,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;YAC3B,MAAM,IAAI,GAAG,IAAI,IAAI,IAAI,CAAC,KAAK,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;YACnE,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,8BAA8B,EAAE,EAAE,aAAa,CAAC,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7H,CAAC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAiB;IACzC,OAAO,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;AAC/C,CAAC"}
|
||||
Vendored
+91
-30
@@ -94,6 +94,12 @@ export async function loadProjects() {
|
||||
export async function loadTasks(projectId) {
|
||||
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
|
||||
state.tasks.set(projectId, tasks);
|
||||
for (const t of tasks) {
|
||||
if (t.status === "active") {
|
||||
taskFeed(t.id);
|
||||
}
|
||||
}
|
||||
dropClosedFeeds();
|
||||
await Promise.all(tasks.filter((t) => t.status === "active").map((t) => loadSessions(t.id)));
|
||||
notify("tasks");
|
||||
}
|
||||
@@ -148,31 +154,41 @@ export function focusTab(tab) {
|
||||
class Feed {
|
||||
transcriptTopic;
|
||||
streamTopic;
|
||||
store;
|
||||
readyTopic;
|
||||
shown;
|
||||
events = [];
|
||||
ready = false;
|
||||
last = 0;
|
||||
buffer = [];
|
||||
timer = 0;
|
||||
constructor(transcriptTopic, streamTopic, store) {
|
||||
constructor(transcriptTopic, streamTopic, readyTopic, shown) {
|
||||
this.transcriptTopic = transcriptTopic;
|
||||
this.streamTopic = streamTopic;
|
||||
this.store = store;
|
||||
}
|
||||
reset() {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = 0;
|
||||
this.buffer = [];
|
||||
this.events = [];
|
||||
this.last = 0;
|
||||
this.store(this.events);
|
||||
this.readyTopic = readyTopic;
|
||||
this.shown = shown;
|
||||
}
|
||||
push = (ev) => {
|
||||
if (ev.seq === 0 && (!this.ready || !this.shown())) {
|
||||
return;
|
||||
}
|
||||
this.buffer.push(ev);
|
||||
if (!this.timer) {
|
||||
if (this.ready && !this.timer) {
|
||||
this.timer = setTimeout(() => this.flush(), 0);
|
||||
}
|
||||
};
|
||||
load = (history) => {
|
||||
if (this.ready) {
|
||||
return;
|
||||
}
|
||||
this.ready = true;
|
||||
this.buffer = [...history, ...this.buffer];
|
||||
this.flush();
|
||||
if (this.shown()) {
|
||||
notify(this.readyTopic);
|
||||
}
|
||||
};
|
||||
flush() {
|
||||
clearTimeout(this.timer);
|
||||
const events = this.buffer;
|
||||
this.buffer = [];
|
||||
this.timer = 0;
|
||||
@@ -184,27 +200,75 @@ class Feed {
|
||||
}
|
||||
this.append(stored);
|
||||
stored = [];
|
||||
if (this.shown()) {
|
||||
notify(this.streamTopic, ev);
|
||||
}
|
||||
}
|
||||
this.append(stored);
|
||||
}
|
||||
append(events) {
|
||||
const fresh = events.filter((ev) => ev.seq > this.last);
|
||||
const fresh = [];
|
||||
for (const ev of events) {
|
||||
if (ev.seq > this.last) {
|
||||
fresh.push(ev);
|
||||
this.last = ev.seq;
|
||||
}
|
||||
}
|
||||
if (fresh.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.events.push(...fresh);
|
||||
this.last = fresh.at(-1).seq;
|
||||
if (this.shown()) {
|
||||
notify(this.transcriptTopic, fresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
const historyPage = 1000;
|
||||
const taskFeeds = new Map();
|
||||
const leadFeed = new Feed("lead.transcript", "lead.stream", "lead.ready", () => true);
|
||||
state.leadTranscript = leadFeed.events;
|
||||
async function loadHistory(taskId, feed) {
|
||||
const events = [];
|
||||
for (;;) {
|
||||
const page = await get("/api/tasks/{taskId}/events", { path: { taskId }, query: { after: events.at(-1)?.seq ?? 0, limit: historyPage } });
|
||||
events.push(...page);
|
||||
if (page.length < historyPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
feed.load(events);
|
||||
}
|
||||
const historyLoads = new Set();
|
||||
function taskFeed(taskId) {
|
||||
let feed = taskFeeds.get(taskId);
|
||||
if (!feed) {
|
||||
feed = new Feed("transcript", "stream", "ready", () => state.selectedTaskId === taskId);
|
||||
taskFeeds.set(taskId, feed);
|
||||
}
|
||||
if (feed.ready || historyLoads.has(feed)) {
|
||||
return feed;
|
||||
}
|
||||
const loading = feed;
|
||||
historyLoads.add(loading);
|
||||
loadHistory(taskId, loading)
|
||||
.catch((err) => console.error("load task history", taskId, err))
|
||||
.finally(() => historyLoads.delete(loading));
|
||||
return feed;
|
||||
}
|
||||
function dropClosedFeeds() {
|
||||
for (const taskId of taskFeeds.keys()) {
|
||||
const task = findTask(taskId);
|
||||
if (taskId !== state.selectedTaskId && task && task.status !== "active") {
|
||||
taskFeeds.delete(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
export function transcriptLoading() {
|
||||
return state.selectedTaskId !== undefined && taskFeeds.get(state.selectedTaskId)?.ready !== true;
|
||||
}
|
||||
export function leadLoading() {
|
||||
return !leadFeed.ready;
|
||||
}
|
||||
const taskFeed = new Feed("transcript", "stream", (events) => {
|
||||
state.transcript = events;
|
||||
});
|
||||
const leadFeed = new Feed("lead.transcript", "lead.stream", (events) => {
|
||||
state.leadTranscript = events;
|
||||
});
|
||||
let closeTaskStream;
|
||||
const visitsKey = "byre.visits";
|
||||
export function lastVisits() {
|
||||
return JSON.parse(localStorage.getItem(visitsKey) ?? "{}");
|
||||
@@ -218,23 +282,20 @@ function markVisited(taskId) {
|
||||
export function selectTask(taskId) {
|
||||
markVisited(state.selectedTaskId);
|
||||
markVisited(taskId);
|
||||
closeTaskStream?.();
|
||||
closeTaskStream = undefined;
|
||||
taskFeed.reset();
|
||||
state.selectedTaskId = taskId;
|
||||
state.transcript = taskId ? taskFeed(taskId).events : [];
|
||||
state.pinned = undefined;
|
||||
state.reviewed = undefined;
|
||||
dropClosedFeeds();
|
||||
notify("selection");
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
closeTaskStream = subscribe(0, { task_id: taskId }, taskFeed.push, () => undefined);
|
||||
}
|
||||
export function startLeadStream() {
|
||||
leadFeed.reset();
|
||||
subscribe(0, { lead: true }, leadFeed.push, () => undefined);
|
||||
subscribe(0, { lead: true }, leadFeed.push, () => undefined, () => leadFeed.load([]));
|
||||
}
|
||||
export function handleEvent(ev) {
|
||||
if (ev.task_id) {
|
||||
taskFeeds.get(ev.task_id)?.push(ev);
|
||||
}
|
||||
if (ev.seq === 0) {
|
||||
notify("event", ev);
|
||||
return;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+8
-1
@@ -4,7 +4,7 @@ import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
|
||||
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state } from "./state.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, transcriptLoading } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
import { fillTodoLinks } from "./todolink.js";
|
||||
export const models = [
|
||||
@@ -24,6 +24,7 @@ const quietReply = "NO_REPLY";
|
||||
const taskSource = {
|
||||
key: () => state.selectedTaskId,
|
||||
events: () => state.transcript,
|
||||
loading: transcriptLoading,
|
||||
session: () => (state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined),
|
||||
root: () => (state.selectedTaskId ? (findTask(state.selectedTaskId)?.worktree_path ?? "") : ""),
|
||||
taskId: () => state.selectedTaskId,
|
||||
@@ -31,6 +32,7 @@ const taskSource = {
|
||||
refreshTopics: ["tasks", "sessions"],
|
||||
transcriptTopic: "transcript",
|
||||
streamTopic: "stream",
|
||||
readyTopic: "ready",
|
||||
empty: "No task selected. Ask your lead, or pick one with ⌘K.",
|
||||
canSend: () => true,
|
||||
placeholder: "Message the agent. Paste or drop images to attach them.",
|
||||
@@ -165,6 +167,7 @@ export function mountChat(root, src, makeHead) {
|
||||
let context = new Map();
|
||||
const headImpl = makeHead(() => renderHead());
|
||||
const scanner = h("div", { class: "scanner", hidden: true });
|
||||
const loading = h("div", { class: "empty" }, "loading…");
|
||||
const setRunning = (running) => {
|
||||
transcript.classList.toggle("running", running);
|
||||
scanner.hidden = !running;
|
||||
@@ -459,6 +462,9 @@ export function mountChat(root, src, makeHead) {
|
||||
return;
|
||||
}
|
||||
replace(transcript, scanner);
|
||||
if (src.loading()) {
|
||||
transcript.prepend(loading);
|
||||
}
|
||||
appendEvents(src.events());
|
||||
renderComposer();
|
||||
};
|
||||
@@ -470,6 +476,7 @@ export function mountChat(root, src, makeHead) {
|
||||
}),
|
||||
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh)),
|
||||
onEach([src.streamTopic], (ev) => appendDelta(ev)),
|
||||
onEach([src.readyTopic], () => loading.remove()),
|
||||
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0])),
|
||||
];
|
||||
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -123,6 +123,7 @@ export function subscribe(
|
||||
filter: { task_id?: string; lead?: boolean },
|
||||
onEvent: (ev: ByreEvent) => void,
|
||||
onState: (connected: boolean) => void,
|
||||
onReady: () => void,
|
||||
): () => void {
|
||||
let last = after;
|
||||
let heard = Date.now();
|
||||
@@ -155,6 +156,10 @@ export function subscribe(
|
||||
s.addEventListener("ping", () => {
|
||||
heard = Date.now();
|
||||
});
|
||||
s.addEventListener("ready", () => {
|
||||
heard = Date.now();
|
||||
onReady();
|
||||
});
|
||||
s.onmessage = (msg: MessageEvent<string>) => {
|
||||
heard = Date.now();
|
||||
const ev = JSON.parse(msg.data) as ByreEvent;
|
||||
|
||||
+8
-2
@@ -78,10 +78,16 @@ async function main(): Promise<void> {
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
subscribe(status.last_event_seq, {}, handleEvent, (connected) => {
|
||||
subscribe(
|
||||
status.last_event_seq,
|
||||
{},
|
||||
handleEvent,
|
||||
(connected) => {
|
||||
state.connected = connected;
|
||||
notify("connection");
|
||||
});
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
addEventListener("hashchange", route);
|
||||
route();
|
||||
|
||||
+3
-1
@@ -1,11 +1,12 @@
|
||||
import { post } from "./api.js";
|
||||
import { h } from "./dom.js";
|
||||
import { currentLead, state } from "./state.js";
|
||||
import { currentLead, leadLoading, state } from "./state.js";
|
||||
import { compactButton, mountChat, type ChatSource, type Head } from "./task.js";
|
||||
|
||||
const leadSource: ChatSource = {
|
||||
key: () => "lead",
|
||||
events: () => state.leadTranscript,
|
||||
loading: leadLoading,
|
||||
session: currentLead,
|
||||
root: () => "",
|
||||
taskId: () => undefined,
|
||||
@@ -13,6 +14,7 @@ const leadSource: ChatSource = {
|
||||
refreshTopics: ["lead.sessions"],
|
||||
transcriptTopic: "lead.transcript",
|
||||
streamTopic: "lead.stream",
|
||||
readyTopic: "lead.ready",
|
||||
empty: "",
|
||||
canSend: () => true,
|
||||
placeholder: "Message your lead. Paste or drop images to attach them.",
|
||||
|
||||
+99
-33
@@ -17,7 +17,9 @@ export type Topic =
|
||||
| "focus"
|
||||
| "lead.sessions"
|
||||
| "lead.transcript"
|
||||
| "lead.stream";
|
||||
| "lead.stream"
|
||||
| "ready"
|
||||
| "lead.ready";
|
||||
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "project"];
|
||||
|
||||
@@ -130,6 +132,12 @@ export async function loadProjects(): Promise<void> {
|
||||
export async function loadTasks(projectId: string): Promise<void> {
|
||||
const tasks = await get("/api/projects/{projectId}/tasks", { path: { projectId } });
|
||||
state.tasks.set(projectId, tasks);
|
||||
for (const t of tasks) {
|
||||
if (t.status === "active") {
|
||||
taskFeed(t.id);
|
||||
}
|
||||
}
|
||||
dropClosedFeeds();
|
||||
await Promise.all(tasks.filter((t) => t.status === "active").map((t) => loadSessions(t.id)));
|
||||
notify("tasks");
|
||||
}
|
||||
@@ -193,7 +201,8 @@ export function focusTab(tab: TaskTab): void {
|
||||
}
|
||||
|
||||
class Feed {
|
||||
events: ByreEvent[] = [];
|
||||
readonly events: ByreEvent[] = [];
|
||||
ready = false;
|
||||
private last = 0;
|
||||
private buffer: ByreEvent[] = [];
|
||||
private timer = 0;
|
||||
@@ -201,26 +210,34 @@ class Feed {
|
||||
constructor(
|
||||
private readonly transcriptTopic: Topic,
|
||||
private readonly streamTopic: Topic,
|
||||
private readonly store: (events: ByreEvent[]) => void,
|
||||
private readonly readyTopic: Topic,
|
||||
private readonly shown: () => boolean,
|
||||
) {}
|
||||
|
||||
reset(): void {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = 0;
|
||||
this.buffer = [];
|
||||
this.events = [];
|
||||
this.last = 0;
|
||||
this.store(this.events);
|
||||
}
|
||||
|
||||
readonly push = (ev: ByreEvent): void => {
|
||||
if (ev.seq === 0 && (!this.ready || !this.shown())) {
|
||||
return;
|
||||
}
|
||||
this.buffer.push(ev);
|
||||
if (!this.timer) {
|
||||
if (this.ready && !this.timer) {
|
||||
this.timer = setTimeout(() => this.flush(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
readonly load = (history: ByreEvent[]): void => {
|
||||
if (this.ready) {
|
||||
return;
|
||||
}
|
||||
this.ready = true;
|
||||
this.buffer = [...history, ...this.buffer];
|
||||
this.flush();
|
||||
if (this.shown()) {
|
||||
notify(this.readyTopic);
|
||||
}
|
||||
};
|
||||
|
||||
private flush(): void {
|
||||
clearTimeout(this.timer);
|
||||
const events = this.buffer;
|
||||
this.buffer = [];
|
||||
this.timer = 0;
|
||||
@@ -232,30 +249,84 @@ class Feed {
|
||||
}
|
||||
this.append(stored);
|
||||
stored = [];
|
||||
if (this.shown()) {
|
||||
notify(this.streamTopic, ev);
|
||||
}
|
||||
}
|
||||
this.append(stored);
|
||||
}
|
||||
|
||||
private append(events: ByreEvent[]): void {
|
||||
const fresh = events.filter((ev) => ev.seq > this.last);
|
||||
const fresh: ByreEvent[] = [];
|
||||
for (const ev of events) {
|
||||
if (ev.seq > this.last) {
|
||||
fresh.push(ev);
|
||||
this.last = ev.seq;
|
||||
}
|
||||
}
|
||||
if (fresh.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.events.push(...fresh);
|
||||
this.last = fresh.at(-1)!.seq;
|
||||
if (this.shown()) {
|
||||
notify(this.transcriptTopic, fresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const taskFeed = new Feed("transcript", "stream", (events) => {
|
||||
state.transcript = events;
|
||||
});
|
||||
const leadFeed = new Feed("lead.transcript", "lead.stream", (events) => {
|
||||
state.leadTranscript = events;
|
||||
});
|
||||
const historyPage = 1000;
|
||||
|
||||
let closeTaskStream: (() => void) | undefined;
|
||||
const taskFeeds = new Map<string, Feed>();
|
||||
const leadFeed = new Feed("lead.transcript", "lead.stream", "lead.ready", () => true);
|
||||
state.leadTranscript = leadFeed.events;
|
||||
|
||||
async function loadHistory(taskId: string, feed: Feed): Promise<void> {
|
||||
const events: ByreEvent[] = [];
|
||||
for (;;) {
|
||||
const page = await get("/api/tasks/{taskId}/events", { path: { taskId }, query: { after: events.at(-1)?.seq ?? 0, limit: historyPage } });
|
||||
events.push(...page);
|
||||
if (page.length < historyPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
feed.load(events);
|
||||
}
|
||||
|
||||
const historyLoads = new Set<Feed>();
|
||||
|
||||
function taskFeed(taskId: string): Feed {
|
||||
let feed = taskFeeds.get(taskId);
|
||||
if (!feed) {
|
||||
feed = new Feed("transcript", "stream", "ready", () => state.selectedTaskId === taskId);
|
||||
taskFeeds.set(taskId, feed);
|
||||
}
|
||||
if (feed.ready || historyLoads.has(feed)) {
|
||||
return feed;
|
||||
}
|
||||
const loading = feed;
|
||||
historyLoads.add(loading);
|
||||
loadHistory(taskId, loading)
|
||||
.catch((err: unknown) => console.error("load task history", taskId, err))
|
||||
.finally(() => historyLoads.delete(loading));
|
||||
return feed;
|
||||
}
|
||||
|
||||
function dropClosedFeeds(): void {
|
||||
for (const taskId of taskFeeds.keys()) {
|
||||
const task = findTask(taskId);
|
||||
if (taskId !== state.selectedTaskId && task && task.status !== "active") {
|
||||
taskFeeds.delete(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function transcriptLoading(): boolean {
|
||||
return state.selectedTaskId !== undefined && taskFeeds.get(state.selectedTaskId)?.ready !== true;
|
||||
}
|
||||
|
||||
export function leadLoading(): boolean {
|
||||
return !leadFeed.ready;
|
||||
}
|
||||
|
||||
const visitsKey = "byre.visits";
|
||||
|
||||
@@ -273,27 +344,22 @@ function markVisited(taskId: string | undefined): void {
|
||||
export function selectTask(taskId: string | undefined): void {
|
||||
markVisited(state.selectedTaskId);
|
||||
markVisited(taskId);
|
||||
closeTaskStream?.();
|
||||
closeTaskStream = undefined;
|
||||
taskFeed.reset();
|
||||
state.selectedTaskId = taskId;
|
||||
state.transcript = taskId ? taskFeed(taskId).events : [];
|
||||
state.pinned = undefined;
|
||||
state.reviewed = undefined;
|
||||
dropClosedFeeds();
|
||||
notify("selection");
|
||||
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeTaskStream = subscribe(0, { task_id: taskId }, taskFeed.push, () => undefined);
|
||||
}
|
||||
|
||||
export function startLeadStream(): void {
|
||||
leadFeed.reset();
|
||||
subscribe(0, { lead: true }, leadFeed.push, () => undefined);
|
||||
subscribe(0, { lead: true }, leadFeed.push, () => undefined, () => leadFeed.load([]));
|
||||
}
|
||||
|
||||
export function handleEvent(ev: ByreEvent): void {
|
||||
if (ev.task_id) {
|
||||
taskFeeds.get(ev.task_id)?.push(ev);
|
||||
}
|
||||
if (ev.seq === 0) {
|
||||
notify("event", ev);
|
||||
return;
|
||||
|
||||
+10
-1
@@ -4,7 +4,7 @@ import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
|
||||
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, transcriptLoading, type Topic } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
import { fillTodoLinks } from "./todolink.js";
|
||||
|
||||
@@ -15,6 +15,7 @@ type Review = Schemas["Review"];
|
||||
export type ChatSource = {
|
||||
key: () => string | undefined;
|
||||
events: () => ByreEvent[];
|
||||
loading: () => boolean;
|
||||
session: () => AgentSession | undefined;
|
||||
root: () => string;
|
||||
taskId: () => string | undefined;
|
||||
@@ -22,6 +23,7 @@ export type ChatSource = {
|
||||
refreshTopics: Topic[];
|
||||
transcriptTopic: Topic;
|
||||
streamTopic: Topic;
|
||||
readyTopic: Topic;
|
||||
empty: string;
|
||||
canSend: (sessionState: string) => boolean;
|
||||
placeholder: string;
|
||||
@@ -84,6 +86,7 @@ type Ctx = {
|
||||
const taskSource: ChatSource = {
|
||||
key: () => state.selectedTaskId,
|
||||
events: () => state.transcript,
|
||||
loading: transcriptLoading,
|
||||
session: () => (state.selectedTaskId ? currentSession(state.selectedTaskId) : undefined),
|
||||
root: () => (state.selectedTaskId ? (findTask(state.selectedTaskId)?.worktree_path ?? "") : ""),
|
||||
taskId: () => state.selectedTaskId,
|
||||
@@ -91,6 +94,7 @@ const taskSource: ChatSource = {
|
||||
refreshTopics: ["tasks", "sessions"],
|
||||
transcriptTopic: "transcript",
|
||||
streamTopic: "stream",
|
||||
readyTopic: "ready",
|
||||
empty: "No task selected. Ask your lead, or pick one with ⌘K.",
|
||||
canSend: () => true,
|
||||
placeholder: "Message the agent. Paste or drop images to attach them.",
|
||||
@@ -235,6 +239,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
const headImpl = makeHead(() => renderHead());
|
||||
|
||||
const scanner = h("div", { class: "scanner", hidden: true });
|
||||
const loading = h("div", { class: "empty" }, "loading…");
|
||||
const setRunning = (running: boolean) => {
|
||||
transcript.classList.toggle("running", running);
|
||||
scanner.hidden = !running;
|
||||
@@ -541,6 +546,9 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
return;
|
||||
}
|
||||
replace(transcript, scanner);
|
||||
if (src.loading()) {
|
||||
transcript.prepend(loading);
|
||||
}
|
||||
appendEvents(src.events());
|
||||
renderComposer();
|
||||
};
|
||||
@@ -553,6 +561,7 @@ 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(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0]!)),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user