Gateway: snooze finished threads, and name the files a file approval would change
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

This commit is contained in:
gib committed 2026-11-02 14:20:39 -05:00
1 parent d10df56cc9
commit 4dc4e57dac
8 files changed
+295 -10

No files matched your search

+63 -6
View File
@@ -8,6 +8,7 @@ import type {
Activity,
AgentProvider,
ChangedFile,
PendingFile,
Feed,
FeedItem,
HostStatusDetail,
@@ -185,7 +186,7 @@ function describeStep(item: TurnItem): Omit<Activity, "startedAt"> | null {
function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projection"]["turnItems"]): PendingRequest | null {
const pending = thread.pendingRuntimeRequest;
if (!pending || pending.kind === "auth_refresh") return null;
const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], questions: [] };
const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], files: [], questions: [] };
const item = turnItems.findLast(
(candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id,
);
@@ -196,7 +197,9 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio
{ decision: "accept" as const, label: "Allow" },
{ decision: "decline" as const, label: "Deny" },
];
return { ...base, prompt: item.prompt ?? item.title ?? null, reason: item.reason ?? null, options };
const prompt = item.prompt ?? item.title ?? null;
const files = pending.kind === "file-change" ? pendingFiles(turnItems, item.runId, prompt) : [];
return { ...base, prompt, reason: item.reason ?? null, options, files };
}
if (item && isUserInputRequest(item)) {
return {
@@ -223,6 +226,44 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio
};
}
const PREVIEW_LINES = 4;
const PREVIEW_CHARS = 60;
/**
* The files a file-change approval would touch: the run's unfinished file changes, or failing
* those, the path in the prompt, which Claude words as `Edit: <path>`. Most providers send the
* diff only after the edit runs, so counts and preview are usually empty.
*/
export function pendingFiles(turnItems: TurnItem[], runId: string | null | undefined, prompt: string | null): PendingFile[] {
const files = turnItems
.filter(
(item) =>
item.type === "file_change" &&
item.fileName &&
(!runId || item.runId === runId) &&
item.status !== "completed" &&
item.status !== "failed" &&
item.status !== "cancelled",
)
.map((item) => {
const preview = (item.diffStr ?? "")
.split("\n")
.filter((line) => /^[+-]/.test(line) && !/^(\+\+\+|---)( |$)/.test(line))
.slice(0, PREVIEW_LINES)
.map((line) => (line.length > PREVIEW_CHARS ? `${line.slice(0, PREVIEW_CHARS)}…` : line));
return {
path: item.fileName!,
name: item.fileName!.split("/").pop()!,
additions: item.additions ?? null,
deletions: item.deletions ?? null,
preview,
};
});
if (files.length > 0) return files;
const path = prompt?.match(/^\w+: (\S.*)$/)?.[1]?.trim();
return path ? [{ path, name: path.split("/").pop()!, additions: null, deletions: null, preview: [] }] : [];
}
/** Per-thread summaries, refetched when a thread's `updatedAt` changes. */
export class SummaryCache {
private readonly entries = new Map<string, { updatedAt: string; fetchedAt: number; summary: ThreadSummary }>();
@@ -361,6 +402,7 @@ function feedItem(
updatedAt: thread.updatedAt,
lastMessage: summary?.lastAssistantMessage ?? null,
activity: summary?.activity ?? null,
snoozedUntil: isSnoozed(thread, Date.now()) ? thread.snoozedUntil! : null,
changes: summary?.changedFiles.length
? {
files: summary.changedFiles.length,
@@ -442,8 +484,12 @@ export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promis
};
}
/** True while a snooze is set and its time has not come. */
const isSnoozed = (thread: ThreadShell, now: number) =>
!!thread.snoozedUntil && Date.parse(thread.snoozedUntil) > now;
/** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */
export function feedCandidates(hosts: T3Host[]) {
export function feedCandidates(hosts: T3Host[], now = Date.now()) {
const found: Candidate[] = [];
for (const host of hosts) {
for (const thread of host.threads.values()) {
@@ -457,13 +503,18 @@ export function feedCandidates(hosts: T3Host[]) {
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
b.thread.updatedAt.localeCompare(a.thread.updatedAt);
const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
const finished = candidates.filter(({ phase }) => phase === "completed" || phase === "failed");
return {
needsYou: candidates
.filter(({ phase }) => isWaiting(phase))
.sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))),
working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest),
/** Finished and failed threads not yet settled, however old. */
recent: candidates.filter(({ phase }) => phase === "completed" || phase === "failed").sort(byNewest),
/** Finished and failed threads not yet settled or snoozed, however old. */
recent: finished.filter(({ thread }) => !isSnoozed(thread, now)).sort(byNewest),
/** Finished threads snoozed for later, the soonest to come back first. */
snoozed: finished
.filter(({ thread }) => isSnoozed(thread, now))
.sort((a, b) => a.thread.snoozedUntil!.localeCompare(b.thread.snoozedUntil!)),
};
}
@@ -474,7 +525,7 @@ const toItem =
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<Feed> {
const { needsYou, working, recent: allRecent } = feedCandidates(hosts);
const { needsYou, working, recent: allRecent, snoozed } = feedCandidates(hosts);
const recent = allRecent.slice(0, RECENT_LIMIT);
const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate]));
@@ -488,6 +539,7 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<F
working: working.map(toItem(cache)),
recent: recent.map(toItem(cache)),
recentTotal: allRecent.length,
snoozedTotal: snoozed.length,
hosts: hosts.map(hostSummary),
};
}
@@ -500,6 +552,11 @@ export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentRespons
return { items: feedCandidates(hosts).recent.map(toItem(cache)) };
}
/** Snoozed finished threads, the soonest to come back first. Cached summaries only, like Recent. */
export function buildSnoozed(hosts: T3Host[], cache: SummaryCache): RecentResponse {
return { items: feedCandidates(hosts).snoozed.map(toItem(cache)) };
}
/**
* One thread with its recent messages. Fetches the summary if it is not cached. When the thread
* has no request of its own but a subagent it delegated to is waiting, shows that request.