Files
wristcode/gateway/src/views.ts
T
gib 4dc4e57dac
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Gateway: snooze finished threads, and name the files a file approval would change
2026-11-02 14:20:39 -05:00

688 lines
27 KiB
TypeScript

// Turns T3 state into the shapes in contract.ts. Thread shells carry status but not text, so the
// last message and the pending request's details come from each thread's bounded snapshot. Those
// are large (hundreds of KB), so they are reduced to a small summary and cached per thread until
// the thread's `updatedAt` moves.
import { resolvePhase, threadPhase, type Phase } from "./awareness.ts";
import type {
Activity,
AgentProvider,
ChangedFile,
PendingFile,
Feed,
FeedItem,
HostStatusDetail,
HostSummary,
Message,
PendingRequest,
RecentResponse,
RouteKind,
Summary,
ThreadDetail,
ThreadRow,
HistoryResponse,
SearchResponse,
} from "./contract.ts";
import type { T3Host } from "./t3/host.ts";
import {
isApprovalRequest,
isUserInputRequest,
type BoundedThread,
type ConversationMessage,
type ThreadShell,
type TurnItem,
} from "./t3/types.ts";
const LAST_MESSAGE_CHARS = 280;
const MESSAGE_CHARS = 2000;
const MESSAGES_PER_THREAD = 20;
const ACTIVITY_CHARS = 80;
/** How long a running thread's summary stays fresh. Its steps change without moving `updatedAt`. */
const RUNNING_SUMMARY_MS = 5_000;
const THREAD_LIST_LIMIT = 50;
const RECENT_LIMIT = 10;
/** How long a feed request waits for summaries it does not have yet. */
const SUMMARY_WAIT_MS = 2_000;
export interface ThreadSummary {
messages: Message[];
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;
}
/** Trims text for a small screen: collapses whitespace and cuts at a word near `max`. */
export function trimText(text: string, max: number): string {
const flat = text.replace(/\s+/g, " ").trim();
if (flat.length <= max) return flat;
const cut = flat.slice(0, max);
const space = cut.lastIndexOf(" ");
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** Reduces a bounded thread snapshot to what the watch shows. */
export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSummary {
const { messages, turnItems } = bounded.projection;
const visible = visibleMessages(messages);
const lastAssistant = visible.findLast((message) => message.role === "assistant");
const start = Math.max(0, visible.length - MESSAGES_PER_THREAD);
return {
messages: visible.slice(start).map(toMessage),
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
request: pendingRequest(thread, turnItems),
activity: currentActivity(turnItems),
changedFiles: latestChanges(turnItems),
earlierCursor: windowCursor(start, bounded),
};
}
const visibleMessages = (messages: ConversationMessage[]) =>
messages.filter((message) => message.text.trim().length > 0 && (message.role === "user" || message.role === "assistant"));
const toMessage = (message: Pick<ConversationMessage, "id" | "role" | "text" | "createdAt">): Message => ({
id: message.id,
role: message.role as Message["role"],
text: message.text.length > MESSAGE_CHARS ? `${message.text.slice(0, MESSAGE_CHARS)}…` : message.text,
createdAt: message.createdAt,
});
// History cursors are the gateway's own. `w<n>` means the bounded window's messages before index n,
// which the thread detail leaves out past its 20. `h<cursor>` is T3's cursor for older pages.
/** The cursor for what comes before the window's message `start`. */
function windowCursor(start: number, bounded: BoundedThread): string | null {
if (start > 0) return `w${start}`;
return bounded.hasMoreHistory && bounded.historyCursor ? `h${bounded.historyCursor}` : null;
}
/**
* The messages before `cursor`: 20 from the bounded window, or one T3 page, which can hold more.
* T3 pages without messages are skipped, three at most.
*/
export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: string): Promise<HistoryResponse> {
if (cursor.startsWith("w")) {
const end = Number(cursor.slice(1));
const bounded = await host.bounded(thread.id);
const visible = visibleMessages(bounded.projection.messages);
const start = Math.max(0, Math.min(end, visible.length) - MESSAGES_PER_THREAD);
return { messages: visible.slice(start, end).map(toMessage), earlierCursor: windowCursor(start, bounded) };
}
let next: string | null = cursor.slice(1);
for (let pages = 0; next && pages < 3; pages++) {
const page = await host.history(thread.id, next);
next = page.hasMoreHistory ? page.nextCursor : null;
const messages = page.items
.map(({ item }) => item)
.filter(
(item) =>
(item.type === "user_message" || item.type === "assistant_message") && (item.text ?? "").trim().length > 0,
)
.map((item) =>
toMessage({
id: item.messageId ?? "",
role: item.type === "user_message" ? "user" : "assistant",
text: item.text ?? "",
createdAt: item.startedAt ?? item.updatedAt ?? "",
}),
);
if (messages.length > 0) return { messages, earlierCursor: next && `h${next}` };
}
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--) {
const item = turnItems[i]!;
if (item.status !== "running" && item.status !== "pending") continue;
const step = describeStep(item);
if (step) return { ...step, text: trimText(step.text, ACTIVITY_CHARS), startedAt: item.startedAt ?? null };
}
return null;
}
function describeStep(item: TurnItem): Omit<Activity, "startedAt"> | null {
switch (item.type) {
case "command_execution":
return item.monitor || typeof item.input !== "string"
? null
: { kind: "command", text: item.input.split("\n").find((line) => line.trim()) ?? item.input };
case "file_change":
return item.fileName ? { kind: "edit", text: item.fileName.split("/").pop()! } : null;
case "file_search":
return { kind: "search", text: item.pattern ?? item.title ?? "files" };
case "web_search":
return { kind: "web", text: item.title ?? "the web" };
case "subagent":
return { kind: "subagent", text: item.title ?? "Subagent" };
case "reasoning":
return { kind: "thinking", text: "Thinking" };
case "dynamic_tool":
return item.toolName || item.title ? { kind: "tool", text: (item.toolName ?? item.title)! } : null;
default:
return 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: [], files: [], questions: [] };
const item = turnItems.findLast(
(candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id,
);
if (item && isApprovalRequest(item)) {
const options = item.options?.length
? item.options.map(({ decision, label, warning }) => ({ decision, label, ...(warning ? { warning } : {}) }))
: [
{ decision: "accept" as const, label: "Allow" },
{ decision: "decline" as const, label: "Deny" },
];
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 {
...base,
questions: item.questions.map((question) => ({
id: question.id,
header: question.header,
question: question.question,
options: question.options.map((option) => ({ label: option.label, value: option.value ?? option.label })),
allowCustomAnswer: question.allowCustomAnswer ?? false,
multiSelect: question.multiSelect ?? false,
})),
};
}
// The turn item may not be in the bounded window yet. Offer the plain choices for approvals.
return pending.kind === "user_input"
? base
: {
...base,
options: [
{ decision: "accept", label: "Allow" },
{ decision: "decline", label: "Deny" },
],
};
}
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 }>();
private readonly inflight = new Map<string, Promise<ThreadSummary>>();
constructor(private readonly log: (message: string) => void = () => {}) {}
/** The cached summary, if it matches the thread's current version and, while running, is recent. */
peek(host: T3Host, thread: ThreadShell): ThreadSummary | undefined {
const entry = this.entries.get(key(host, thread.id));
if (entry?.updatedAt !== thread.updatedAt) return undefined;
if (thread.activeRunId !== null && Date.now() - entry.fetchedAt > RUNNING_SUMMARY_MS) return undefined;
return entry.summary;
}
get(host: T3Host, thread: ThreadShell): Promise<ThreadSummary> {
const cached = this.peek(host, thread);
if (cached) return Promise.resolve(cached);
const k = `${key(host, thread.id)}@${thread.updatedAt}`;
const fetchedAt = Date.now();
const running = this.inflight.get(k);
if (running) return running;
const promise = host
.bounded(thread.id)
.then((bounded) => {
const summary = summarize(bounded, thread);
this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, fetchedAt, summary });
return summary;
})
.finally(() => this.inflight.delete(k));
this.inflight.set(k, promise);
return promise;
}
forget(host: T3Host, threadId: string) {
this.entries.delete(key(host, threadId));
}
}
const key = (host: T3Host, threadId: string) => `${host.id}:${threadId}`;
export const hostSummary = (host: T3Host): HostSummary => ({
id: host.id,
label: host.label,
status: host.status,
error: host.lastError,
});
/** A host with its routes, for the status screens. `tokenExpiresAt` comes from the hosts table. */
export function hostStatusDetail(host: T3Host, tokenExpiresAt: string | null): HostStatusDetail {
return {
...hostSummary(host),
activeUrl: host.status === "online" ? host.url : null,
routes: host.config.urls.map((url) => {
const attempt = host.routeAttempts.get(url);
return {
url,
kind: routeKind(url),
result: attempt ? (attempt.ok ? "ok" : "failed") : "untried",
error: attempt?.error ?? null,
checkedAt: attempt?.at ?? null,
};
}),
onlineSince: host.onlineSince,
lastOnlineAt: host.lastOnlineAt,
nextRetryAt: host.nextRetryAt,
tokenExpiresAt,
serverVersion: host.serverVersion,
};
}
export function routeKind(url: string): RouteKind {
const { protocol, hostname } = new URL(url);
if (protocol === "https:") return "public";
return hostname.includes("vpn") ? "vpn" : "direct";
}
/** A thread on the feed. With `via`, the card shows that subagent's request on this thread. */
interface Candidate {
host: T3Host;
thread: ThreadShell;
phase: Phase;
via?: { host: T3Host; thread: ThreadShell; phase: Phase };
}
const isWaiting = (phase: Phase | null) => phase === "waiting_for_approval" || phase === "waiting_for_input";
/** The thread that delegated `child`, if the gateway is connected to its host. */
function delegator(hosts: T3Host[], child: ThreadShell): { host: T3Host; thread: ThreadShell } | null {
const from = child.delegatedFrom;
if (!from) return null;
const host = hosts.find((candidate) => candidate.config.environmentId === from.environmentId);
const thread = host?.threads.get(from.threadId);
return host && thread ? { host, thread } : null;
}
/**
* Moves each waiting subagent's request onto the card of the thread that delegated it, the oldest
* request first. A subagent keeps its own card when its parent is unknown, hidden, archived, or
* already showing a request.
*/
function attachDelegates(candidates: Candidate[], hosts: T3Host[]): Candidate[] {
const result = [...candidates];
const delegates = candidates
.filter(({ thread, phase }) => thread.delegatedFrom && isWaiting(phase))
.sort((a, b) => requestAge(a.thread).localeCompare(requestAge(b.thread)));
for (const child of delegates) {
const parent = delegator(hosts, child.thread);
const index = parent ? result.findIndex(({ host, thread }) => host === parent.host && thread.id === parent.thread.id) : -1;
const card = result[index];
const own = result.indexOf(child);
if (!card || own === -1 || card.via || isWaiting(card.phase)) continue;
result[index] = { ...card, phase: child.phase, via: { host: child.host, thread: child.thread, phase: child.phase } };
result.splice(own, 1);
}
return result;
}
const requestAge = (thread: ThreadShell) => thread.pendingRuntimeRequest?.createdAt ?? "";
function feedItem(
host: T3Host,
thread: ThreadShell,
phase: Phase,
summary: ThreadSummary | undefined,
via?: { host: T3Host; thread: ThreadShell; summary: ThreadSummary | undefined },
): FeedItem {
return {
hostId: host.id,
hostLabel: host.label,
threadId: thread.id,
projectId: thread.projectId,
projectTitle: host.projects.get(thread.projectId)?.title ?? "",
title: thread.title,
phase,
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,
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,
};
}
/** The longest waiting subagent that `parent` delegated to, if any. */
function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Candidate | undefined {
let oldest: Candidate | undefined;
for (const host of hosts) {
for (const thread of host.threads.values()) {
const from = thread.delegatedFrom;
if (from?.threadId !== parent.id || from.environmentId !== parentHost.config.environmentId) continue;
const phase = threadPhase(thread);
if (!isWaiting(phase) || thread.archivedAt || thread.deletedAt) continue;
if (!oldest || requestAge(thread) < requestAge(oldest.thread)) oldest = { host, thread, phase: phase! };
}
}
return oldest;
}
/** Counts for the complication, from thread shells alone. */
/**
* 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 (!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,
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,
};
}
/** 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[], now = Date.now()) {
const found: Candidate[] = [];
for (const host of hosts) {
for (const thread of host.threads.values()) {
const phase = threadPhase(thread);
if (phase === null || thread.archivedAt || thread.deletedAt) continue;
found.push({ host, thread, phase });
}
}
const candidates = attachDelegates(found, hosts);
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 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!)),
};
}
const toItem =
(cache: SummaryCache) =>
({ host, thread, phase, via }: Candidate): FeedItem =>
feedItem(host, thread, phase, cache.peek(host, thread), via && { ...via, summary: cache.peek(via.host, via.thread) });
/** 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, snoozed } = feedCandidates(hosts);
const recent = allRecent.slice(0, RECENT_LIMIT);
const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate]));
const missing = shown
.filter(({ host, thread }) => !cache.peek(host, thread))
.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined));
if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]);
return {
needsYou: needsYou.map(toItem(cache)),
working: working.map(toItem(cache)),
recent: recent.map(toItem(cache)),
recentTotal: allRecent.length,
snoozedTotal: snoozed.length,
hosts: hosts.map(hostSummary),
};
}
/**
* Every unsettled finished thread, for the full Recent list. Uses only summaries already cached,
* since there can be many, so `lastMessage` is null for threads the gateway has not read.
*/
export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentResponse {
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.
*/
export async function buildThreadDetail(
host: T3Host,
thread: ThreadShell,
cache: SummaryCache,
hosts: T3Host[],
): Promise<ThreadDetail> {
const summary = await cache.get(host, thread);
const own = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed");
const child = isWaiting(own) ? undefined : waitingDelegate(hosts, host, thread);
const via = child && { host: child.host, thread: child.thread, summary: await cache.get(child.host, child.thread).catch(() => undefined) };
return {
item: feedItem(host, thread, child?.phase ?? own, summary, via),
messages: summary.messages,
canStop: thread.activeRunId !== null,
askFirst: thread.runtimeMode === ASK_FIRST,
earlierCursor: summary.earlierCursor,
subagents: subagents(hosts, host, thread),
changedFiles: summary.changedFiles,
};
}
const threadRow = (host: T3Host, thread: ThreadShell): ThreadRow => ({
hostId: host.id,
threadId: thread.id,
title: thread.title,
phase: resolvePhase(thread),
settled: thread.settledOverride === "settled",
updatedAt: thread.updatedAt,
});
const newestFirst = (a: ThreadRow, b: ThreadRow) => b.updatedAt.localeCompare(a.updatedAt);
const isLive = (thread: ThreadShell) => !thread.archivedAt && !thread.deletedAt;
/** The subagents `parent` started, its own and the ones it delegated to on any host. */
function subagents(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): ThreadRow[] {
const rows: ThreadRow[] = [];
for (const host of hosts) {
for (const thread of host.threads.values()) {
if (!isLive(thread)) continue;
const native =
host === parentHost &&
thread.lineage.relationshipToParent === "subagent" &&
thread.lineage.parentThreadId === parent.id;
const from = thread.delegatedFrom;
const delegated = from?.threadId === parent.id && from.environmentId === parentHost.config.environmentId;
if (native || delegated) rows.push(threadRow(host, thread));
}
}
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()]
.filter(
(thread) =>
thread.projectId === projectId &&
isLive(thread) &&
thread.lineage.relationshipToParent !== "subagent" &&
!thread.delegatedFrom,
)
.map((thread) => threadRow(host, thread))
.sort(newestFirst)
.slice(0, THREAD_LIST_LIMIT);
}
/** T3's runtime mode that asks before each action. */
export const ASK_FIRST = "approval-required";
/** The providers on a host that can start a thread now, leaving out legacy models. */
export function agentProviders(host: T3Host): AgentProvider[] {
return (host.serverConfig?.providers ?? [])
.filter(
(provider) =>
provider.enabled &&
provider.installed &&
provider.availability !== "unavailable" &&
(provider.status === "ready" || provider.status === "warning"),
)
.map((provider) => ({
instanceId: provider.instanceId,
name: provider.displayName ?? provider.driver,
canAskFirst: provider.supportedRuntimeModes?.includes(ASK_FIRST) ?? true,
models: provider.models.filter((model) => !model.isLegacy).map((model) => ({ slug: model.slug, name: model.name })),
}))
.filter((provider) => provider.models.length > 0);
}