Gateway: changed files, search across hosts, a richer complication summary, and question options in pushes
Gateway / check (push) Successful in 10s
Gateway / image (push) Successful in 23s

This commit is contained in:
gib committed 2026-11-02 13:39:49 -05:00
1 parent 45251b1a43
commit b08fd9bdb1
13 files changed
+500 -19

No files matched your search

+98 -3
View File
@@ -7,6 +7,7 @@ import { resolvePhase, threadPhase, type Phase } from "./awareness.ts";
import type {
Activity,
AgentProvider,
ChangedFile,
Feed,
FeedItem,
HostStatusDetail,
@@ -19,6 +20,7 @@ import type {
ThreadDetail,
ThreadRow,
HistoryResponse,
SearchResponse,
} from "./contract.ts";
import type { T3Host } from "./t3/host.ts";
import {
@@ -46,6 +48,8 @@ export interface ThreadSummary {
lastAssistantMessage: string | null;
request: PendingRequest | null;
activity: Activity | null;
/** The files the latest run in the window changed. */
changedFiles: ChangedFile[];
/** The history route's cursor for messages before `messages`. */
earlierCursor: string | null;
}
@@ -70,6 +74,7 @@ export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSu
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
request: pendingRequest(thread, turnItems),
activity: currentActivity(turnItems),
changedFiles: latestChanges(turnItems),
earlierCursor: windowCursor(start, bounded),
};
}
@@ -128,6 +133,21 @@ export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: str
return { messages: [], earlierCursor: next && `h${next}` };
}
/** The files the latest run changed, summed per file, most lines changed first. */
export function latestChanges(turnItems: TurnItem[]): ChangedFile[] {
const runId = turnItems.findLast((item) => item.runId)?.runId;
if (!runId) return [];
const files = new Map<string, ChangedFile>();
for (const item of turnItems) {
if (item.type !== "file_change" || item.runId !== runId || !item.fileName) continue;
const file = files.get(item.fileName) ?? { path: item.fileName, name: item.fileName.split("/").pop()!, additions: 0, deletions: 0 };
file.additions += item.additions ?? 0;
file.deletions += item.deletions ?? 0;
files.set(item.fileName, file);
}
return [...files.values()].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
}
/** The newest step still in progress, said briefly. Monitors are left out, as they run for hours. */
export function currentActivity(turnItems: TurnItem[]): Activity | null {
for (let i = turnItems.length - 1; i >= 0; i--) {
@@ -341,6 +361,13 @@ function feedItem(
updatedAt: thread.updatedAt,
lastMessage: summary?.lastAssistantMessage ?? null,
activity: summary?.activity ?? null,
changes: summary?.changedFiles.length
? {
files: summary.changedFiles.length,
additions: summary.changedFiles.reduce((sum, file) => sum + file.additions, 0),
deletions: summary.changedFiles.reduce((sum, file) => sum + file.deletions, 0),
}
: null,
request: (via ? via.summary : summary)?.request ?? null,
via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null,
};
@@ -362,25 +389,55 @@ function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShel
}
/** Counts for the complication, from thread shells alone. */
export function buildSummary(hosts: T3Host[]): Summary {
/**
* Counts for the complication, plus the longest waiting request and the busiest working thread.
* Reads at most those two threads, waiting briefly as the feed does.
*/
export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promise<Summary> {
let needsYou = 0;
let working = 0;
let next: { host: T3Host; thread: ThreadShell } | null = null;
let current: { host: T3Host; thread: ThreadShell } | null = null;
for (const host of hosts) {
for (const thread of host.threads.values()) {
if (thread.archivedAt || thread.deletedAt) continue;
const phase = threadPhase(thread);
if (phase === "starting" || phase === "running") working++;
if (phase === "starting" || phase === "running") {
working++;
if (!current || thread.updatedAt > current.thread.updatedAt) current = { host, thread };
}
if (phase !== "waiting_for_approval" && phase !== "waiting_for_input") continue;
needsYou++;
const waitingSince = thread.pendingRuntimeRequest?.createdAt ?? "";
if (!next || waitingSince < (next.thread.pendingRuntimeRequest?.createdAt ?? "")) next = { host, thread };
}
}
const read = [next, current].filter((pick) => pick !== null);
const missing = read.filter(({ host, thread }) => !cache.peek(host, thread));
if (missing.length > 0) {
await Promise.race([
Promise.all(missing.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined))),
Bun.sleep(SUMMARY_WAIT_MS),
]);
}
const request = next && cache.peek(next.host, next.thread)?.request;
const asked = request?.prompt ?? request?.questions[0]?.question;
return {
needsYou,
working,
next: next && { hostId: next.host.id, threadId: next.thread.id, title: next.thread.title, hostLabel: next.host.label },
next: next && {
hostId: next.host.id,
threadId: next.thread.id,
title: next.thread.title,
hostLabel: next.host.label,
request: asked ? trimText(asked, ACTIVITY_CHARS) : null,
},
current: current && {
hostId: current.host.id,
threadId: current.thread.id,
title: current.thread.title,
activity: cache.peek(current.host, current.thread)?.activity ?? null,
},
hostsOffline: hosts.filter((host) => host.status !== "online").length,
};
}
@@ -464,6 +521,7 @@ export async function buildThreadDetail(
askFirst: thread.runtimeMode === ASK_FIRST,
earlierCursor: summary.earlierCursor,
subagents: subagents(hosts, host, thread),
changedFiles: summary.changedFiles,
};
}
@@ -497,6 +555,43 @@ function subagents(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Th
return rows.sort(newestFirst);
}
/**
* Searches every online host at once and merges the hits, newest match first. Hosts that are
* offline or fail are named in `unsearched`.
*/
export async function searchThreads(hosts: T3Host[], query: string): Promise<SearchResponse> {
const results = await Promise.all(
hosts.map(async (host) => {
if (host.status !== "online") return null;
try {
return { host, matches: await host.search(query) };
} catch {
return null;
}
}),
);
const hits = results.flatMap((result) =>
(result?.matches ?? []).flatMap((match) => {
const thread = result!.host.threads.get(match.threadId);
if (!thread || !isLive(thread)) return [];
const snippet = match.snippet.trim();
return [
{
thread: threadRow(result!.host, thread),
projectTitle: result!.host.projects.get(thread.projectId)?.title ?? "",
hostLabel: result!.host.label,
snippet: snippet && snippet !== thread.title ? trimText(snippet, 160) : null,
at: match.messageCreatedAt ?? thread.updatedAt,
},
];
}),
);
return {
hits: hits.sort((a, b) => b.at.localeCompare(a.at)).map(({ at: _, ...hit }) => hit),
unsearched: hosts.filter((host, i) => results[i] === null).map((host) => host.label),
};
}
/** A project's newest 50 threads, settled ones included. Subagents are listed under their parents. */
export function projectThreads(host: T3Host, projectId: string): ThreadRow[] {
return [...host.threads.values()]