Gateway: activity line, thread history paging, a project's threads, and a thread's subagents
This commit is contained in:
1 parent
6bc9cac6ca
commit
52210d0223
11 files changed
+474
-19
No files matched your search
+34
-2
@@ -77,6 +77,7 @@ FeedItem {
|
||||
"phase": "waiting_for_approval",
|
||||
"updatedAt": "2026-11-01T09:41:00.000Z",
|
||||
"lastMessage": "Parser and tests are written. I want to run the suite.",
|
||||
"activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null,
|
||||
"request": PendingRequest | null,
|
||||
"via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null
|
||||
}
|
||||
@@ -92,6 +93,12 @@ when the gateway cannot find its parent, or the parent already shows a request.
|
||||
`failed`. `lastMessage` is the agent's latest message trimmed to about 280 characters. It can be null
|
||||
for a moment after a thread changes.
|
||||
|
||||
`activity` is the newest step the agent has in progress. `kind` is `command`, `edit`, `search`,
|
||||
`web`, `subagent`, `thinking` or `tool`, and `text` is the command line, the file name (without its
|
||||
directory), the search, the subagent's title or the tool's name, trimmed to about 80 characters.
|
||||
Monitors are left out, since they run for hours. It is null when no step is in progress. The gateway
|
||||
rereads a running thread at most every 5 seconds, so it can lag that much.
|
||||
|
||||
```
|
||||
PendingRequest {
|
||||
"id": "...",
|
||||
@@ -112,11 +119,25 @@ thread". For questions (`kind: "user_input"`), `options` is empty and `questions
|
||||
```
|
||||
GET /v1/threads/:hostId/:threadId
|
||||
|
||||
200 { "item": FeedItem, "messages": [Message], "canStop": true, "askFirst": false }
|
||||
200 { "item": FeedItem, "messages": [Message], "canStop": true, "askFirst": false,
|
||||
"earlierCursor": "w12" | null, "subagents": [ThreadRow] }
|
||||
ThreadRow { "hostId": "...", "threadId": "...", "title": "Check PATH", "phase": "running" | null,
|
||||
"settled": false, "updatedAt": "..." }
|
||||
Message { "id": "...", "role": "assistant", "text": "...", "createdAt": "..." }
|
||||
```
|
||||
|
||||
`messages` holds the last 20, oldest first, each trimmed to 2000 characters.
|
||||
`messages` holds the last 20, oldest first, each trimmed to 2000 characters. `subagents` lists the
|
||||
threads this one started, its own subagents and `delegate_task` children on any host, newest first.
|
||||
A ThreadRow's `phase` is null for a thread that never ran or was stopped.
|
||||
|
||||
```
|
||||
GET /v1/threads/:hostId/:threadId/history?cursor=<earlierCursor>
|
||||
|
||||
200 { "messages": [Message], "earlierCursor": "..." | null }
|
||||
```
|
||||
|
||||
The 20 messages before the ones the watch has, oldest first. Pass the returned `earlierCursor` for
|
||||
the page before that. It is null at the start of the thread. Cursors are opaque.
|
||||
|
||||
```
|
||||
POST /v1/threads/:hostId/:threadId/messages
|
||||
@@ -192,6 +213,17 @@ GET /v1/summary
|
||||
|
||||
`next` is the thread that has waited longest for you, or null.
|
||||
|
||||
## Browse
|
||||
|
||||
```
|
||||
GET /v1/projects/:hostId/:projectId/threads
|
||||
|
||||
200 { "items": [ThreadRow] }
|
||||
```
|
||||
|
||||
A project's newest 50 threads, settled ones included, newest first. Subagents are left out and
|
||||
listed under their parents instead. Hosts and projects come from `GET /v1/projects`.
|
||||
|
||||
## New thread
|
||||
|
||||
```
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SettleRecentResponse,
|
||||
SendMessageRequest,
|
||||
StatusResponse,
|
||||
ThreadListResponse,
|
||||
ThreadModeRequest,
|
||||
} from "./contract.ts";
|
||||
import type { Db, DeviceRow } from "./db.ts";
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
feedCandidates,
|
||||
hostStatusDetail,
|
||||
hostSummary,
|
||||
loadHistory,
|
||||
projectThreads,
|
||||
type SummaryCache,
|
||||
} from "./views.ts";
|
||||
|
||||
@@ -277,6 +280,14 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/projects/:hostId/:projectId/threads": {
|
||||
GET: authed<"/v1/projects/:hostId/:projectId/threads">((req) => {
|
||||
const host = findHost(req.params.hostId);
|
||||
if (!host.projects.has(req.params.projectId)) throw new HttpError(404, "Unknown project.");
|
||||
return json({ items: projectThreads(host, req.params.projectId) } satisfies ThreadListResponse);
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/threads": {
|
||||
POST: authed<"/v1/threads">(async (req) => {
|
||||
const body = await readBody<NewThreadRequest>(req);
|
||||
@@ -308,6 +319,15 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/threads/:hostId/:threadId/history": {
|
||||
GET: authed<"/v1/threads/:hostId/:threadId/history">(async (req) => {
|
||||
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
|
||||
const cursor = new URL(req.url).searchParams.get("cursor") ?? "";
|
||||
if (!/^[wh]./.test(cursor)) throw new HttpError(400, "Send the earlierCursor from the thread.");
|
||||
return json(await loadHistory(host, thread, cursor));
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/threads/:hostId/:threadId/messages": {
|
||||
POST: authed<"/v1/threads/:hostId/:threadId/messages">(async (req) => {
|
||||
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
|
||||
|
||||
@@ -44,7 +44,8 @@ export function threadPhase(thread: AwarenessFields): Phase | null {
|
||||
return phase;
|
||||
}
|
||||
|
||||
function resolvePhase(thread: AwarenessFields): Phase | null {
|
||||
/** A thread's phase with none of threadPhase's hiding, for lists. Null when it never ran or was stopped. */
|
||||
export function resolvePhase(thread: AwarenessFields): Phase | null {
|
||||
const runStatus = thread.activityRunStatus ?? thread.status;
|
||||
const request = thread.pendingRuntimeRequest;
|
||||
if (
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface FeedItem {
|
||||
updatedAt: string;
|
||||
/** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */
|
||||
lastMessage: string | null;
|
||||
/** What a working thread is doing right now. Null when nothing is in progress or not read yet. */
|
||||
activity: Activity | null;
|
||||
request: PendingRequest | null;
|
||||
/**
|
||||
* Set when `request` comes from a subagent this thread delegated to, rather than the thread
|
||||
@@ -124,6 +126,37 @@ export interface FeedItem {
|
||||
via: { hostId: string; threadId: string; title: string } | null;
|
||||
}
|
||||
|
||||
/** One step a thread is taking now. */
|
||||
export interface Activity {
|
||||
kind: "command" | "edit" | "search" | "web" | "subagent" | "thinking" | "tool";
|
||||
/** The command line, file name, search, subagent title or tool name, trimmed. */
|
||||
text: string;
|
||||
/** When the step started, for its elapsed time. */
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
/** A thread in a list: a project's threads, or a thread's subagents. */
|
||||
export interface ThreadRow {
|
||||
hostId: string;
|
||||
threadId: string;
|
||||
title: string;
|
||||
/** Null for a thread that never ran or was stopped. */
|
||||
phase: Phase | null;
|
||||
settled: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponse {
|
||||
items: ThreadRow[];
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
/** Oldest first, just before the messages the watch already has. */
|
||||
messages: Message[];
|
||||
/** Pass to the history route for the page before these. Null at the start of the thread. */
|
||||
earlierCursor: string | null;
|
||||
}
|
||||
|
||||
/** Counts for the watch face complication. Cheap: no thread pages are read. */
|
||||
export interface Summary {
|
||||
needsYou: number;
|
||||
@@ -171,6 +204,10 @@ export interface ThreadDetail {
|
||||
canStop: boolean;
|
||||
/** True when the thread runs in approval-required mode, asking before each action. */
|
||||
askFirst: boolean;
|
||||
/** Pass to the history route for older messages. Null when `messages` starts the thread. */
|
||||
earlierCursor: string | null;
|
||||
/** Subagents and delegated threads this thread started, newest first. */
|
||||
subagents: ThreadRow[];
|
||||
}
|
||||
|
||||
export interface ThreadModeRequest {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type BoundedThread,
|
||||
type Command,
|
||||
type EnvironmentDescriptor,
|
||||
type HistoryPage,
|
||||
type LaunchThreadInput,
|
||||
type Project,
|
||||
type ServerConfig,
|
||||
@@ -175,6 +176,12 @@ export class T3Host {
|
||||
return this.http<BoundedThread>(`/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`);
|
||||
}
|
||||
|
||||
/** One page of conversation history older than `cursor`, from `historyCursor` or a page's `nextCursor`. */
|
||||
history(threadId: string, cursor: string): Promise<HistoryPage> {
|
||||
const query = new URLSearchParams({ cursor, view: "conversation" });
|
||||
return this.http<HistoryPage>(`/api/orchestration/threads/${encodeURIComponent(threadId)}/history?${query}`);
|
||||
}
|
||||
|
||||
/** The session's expiry, from `/api/auth/session`. */
|
||||
async sessionExpiresAt(): Promise<string | null> {
|
||||
const session = await this.http<{ expiresAt?: string }>("/api/auth/session");
|
||||
|
||||
+27
-1
@@ -68,7 +68,7 @@ export interface ThreadShell {
|
||||
lastError?: string | null;
|
||||
modelSelection: ModelSelection;
|
||||
runtimeMode?: string;
|
||||
lineage: { relationshipToParent: Open<"fork" | "subagent"> | null };
|
||||
lineage: { relationshipToParent: Open<"fork" | "subagent"> | null; parentThreadId?: string | null };
|
||||
/** Set on threads an agent started with delegate_task, which T3 does not mark as subagents. */
|
||||
delegatedFrom?: { environmentId: string; threadId: string; title: string };
|
||||
settledOverride?: Open<"settled"> | null;
|
||||
@@ -116,6 +116,22 @@ export interface UserInputQuestion {
|
||||
export interface TurnItem {
|
||||
type: string;
|
||||
title?: string | null;
|
||||
status?: Open<"idle" | "pending" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "interrupted">;
|
||||
startedAt?: string | null;
|
||||
updatedAt?: string;
|
||||
/** A command's input, on `command_execution`. */
|
||||
input?: unknown;
|
||||
/** Set on `command_execution` items that are monitors rather than commands. */
|
||||
monitor?: boolean;
|
||||
/** The file, on `file_change`. */
|
||||
fileName?: string;
|
||||
/** The search, on `file_search`. */
|
||||
pattern?: string;
|
||||
/** The tool, on `dynamic_tool`. */
|
||||
toolName?: string | null;
|
||||
/** The message, on `user_message` and `assistant_message`. */
|
||||
messageId?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestItem extends TurnItem {
|
||||
@@ -141,6 +157,9 @@ export const isUserInputRequest = (item: TurnItem): item is UserInputRequestItem
|
||||
|
||||
export interface BoundedThread {
|
||||
snapshotSequence: number;
|
||||
/** Where `GET /history` continues from, going back. */
|
||||
historyCursor?: string | null;
|
||||
hasMoreHistory?: boolean;
|
||||
projection: {
|
||||
thread: ThreadShell;
|
||||
messages: ConversationMessage[];
|
||||
@@ -148,6 +167,13 @@ export interface BoundedThread {
|
||||
};
|
||||
}
|
||||
|
||||
/** One page of `GET /api/orchestration/threads/:threadId/history`, older than the cursor. */
|
||||
export interface HistoryPage {
|
||||
items: Array<{ item: TurnItem }>;
|
||||
nextCursor: string | null;
|
||||
hasMoreHistory: boolean;
|
||||
}
|
||||
|
||||
export interface EnvironmentDescriptor {
|
||||
environmentId: string;
|
||||
label: string;
|
||||
|
||||
+156
-14
@@ -3,8 +3,9 @@
|
||||
// are large (hundreds of KB), so they are reduced to a small summary and cached per thread until
|
||||
// the thread's `updatedAt` moves.
|
||||
|
||||
import { threadPhase, type Phase } from "./awareness.ts";
|
||||
import { resolvePhase, threadPhase, type Phase } from "./awareness.ts";
|
||||
import type {
|
||||
Activity,
|
||||
AgentProvider,
|
||||
Feed,
|
||||
FeedItem,
|
||||
@@ -16,18 +17,26 @@ import type {
|
||||
RouteKind,
|
||||
Summary,
|
||||
ThreadDetail,
|
||||
ThreadRow,
|
||||
HistoryResponse,
|
||||
} 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;
|
||||
@@ -36,6 +45,9 @@ export interface ThreadSummary {
|
||||
messages: Message[];
|
||||
lastAssistantMessage: string | null;
|
||||
request: PendingRequest | null;
|
||||
activity: Activity | null;
|
||||
/** 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`. */
|
||||
@@ -50,22 +62,101 @@ export function trimText(text: string, max: number): string {
|
||||
/** 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 = messages.filter(
|
||||
(message) => message.text.trim().length > 0 && (message.role === "user" || message.role === "assistant"),
|
||||
);
|
||||
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(-MESSAGES_PER_THREAD).map((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,
|
||||
})),
|
||||
messages: visible.slice(start).map(toMessage),
|
||||
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
|
||||
request: pendingRequest(thread, turnItems),
|
||||
activity: currentActivity(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;
|
||||
}
|
||||
|
||||
/** Up to 20 messages before `cursor`. 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: messages.slice(-MESSAGES_PER_THREAD), earlierCursor: next && `h${next}` };
|
||||
}
|
||||
return { messages: [], earlierCursor: next && `h${next}` };
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
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;
|
||||
@@ -109,28 +200,31 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio
|
||||
|
||||
/** Per-thread summaries, refetched when a thread's `updatedAt` changes. */
|
||||
export class SummaryCache {
|
||||
private readonly entries = new Map<string, { updatedAt: string; summary: ThreadSummary }>();
|
||||
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. */
|
||||
/** 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));
|
||||
return entry?.updatedAt === thread.updatedAt ? entry.summary : undefined;
|
||||
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, summary });
|
||||
this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, fetchedAt, summary });
|
||||
return summary;
|
||||
})
|
||||
.finally(() => this.inflight.delete(k));
|
||||
@@ -241,6 +335,7 @@ function feedItem(
|
||||
phase,
|
||||
updatedAt: thread.updatedAt,
|
||||
lastMessage: summary?.lastAssistantMessage ?? null,
|
||||
activity: summary?.activity ?? null,
|
||||
request: (via ? via.summary : summary)?.request ?? null,
|
||||
via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null,
|
||||
};
|
||||
@@ -362,9 +457,56 @@ export async function buildThreadDetail(
|
||||
messages: summary.messages,
|
||||
canStop: thread.activeRunId !== null,
|
||||
askFirst: thread.runtimeMode === ASK_FIRST,
|
||||
earlierCursor: summary.earlierCursor,
|
||||
subagents: subagents(hosts, host, thread),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
|
||||
|
||||
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
|
||||
import type { T3Host } from "../src/t3/host.ts";
|
||||
import { agentProviders, buildFeed, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
|
||||
import { agentProviders, buildFeed, currentActivity, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
|
||||
|
||||
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
||||
id: "thread-1",
|
||||
@@ -231,3 +231,58 @@ describe("agentProviders", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentActivity", () => {
|
||||
const item = (type: string, status: string, extra: object = {}) => ({ type, status, startedAt: "2026-11-01T09:00:00.000Z", ...extra });
|
||||
|
||||
it("says the newest step still in progress, leaving out monitors", () => {
|
||||
expect(
|
||||
currentActivity([
|
||||
item("file_change", "running", { fileName: "gateway/src/host.ts" }),
|
||||
item("command_execution", "running", { input: "bun test" }),
|
||||
item("command_execution", "running", { input: "tail -f log", monitor: true }),
|
||||
item("assistant_message", "running"),
|
||||
]),
|
||||
).toEqual({ kind: "command", text: "bun test", startedAt: "2026-11-01T09:00:00.000Z" });
|
||||
expect(currentActivity([item("file_change", "running", { fileName: "gateway/src/host.ts" })])?.text).toBe("host.ts");
|
||||
});
|
||||
|
||||
it("is null once every step finished", () => {
|
||||
expect(currentActivity([item("command_execution", "completed", { input: "bun test" })])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("thread history", () => {
|
||||
const messages = Array.from({ length: 45 }, (_, i) => message(i % 2 ? "assistant" : "user", `m${i}`));
|
||||
const window = { snapshotSequence: 1, historyCursor: "t3-cursor", hasMoreHistory: true, projection: { thread: thread(), messages, turnItems: [] } };
|
||||
const host = { bounded: () => Promise.resolve(window) } as unknown as T3Host;
|
||||
|
||||
it("pages back through the bounded window, then hands over to T3's cursor", async () => {
|
||||
expect(summarize(window as BoundedThread, thread()).earlierCursor).toBe("w25");
|
||||
const first = await loadHistory(host, thread(), "w25");
|
||||
expect([first.messages[0]?.text, first.messages.at(-1)?.text, first.earlierCursor]).toEqual(["m5", "m24", "w5"]);
|
||||
const second = await loadHistory(host, thread(), "w5");
|
||||
expect([second.messages.length, second.earlierCursor]).toEqual([5, "ht3-cursor"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectThreads", () => {
|
||||
it("lists a project's threads newest first, settled ones too, without subagents", () => {
|
||||
const host = {
|
||||
id: "Desktop",
|
||||
threads: new Map(
|
||||
[
|
||||
thread({ id: "old", updatedAt: "2026-11-01T08:00:00.000Z", settledOverride: "settled", status: "completed" }),
|
||||
thread({ id: "new", updatedAt: "2026-11-01T10:00:00.000Z" }),
|
||||
thread({ id: "sub", lineage: { relationshipToParent: "subagent", parentThreadId: "new" } }),
|
||||
thread({ id: "gone", archivedAt: "2026-11-01T09:30:00.000Z" }),
|
||||
thread({ id: "elsewhere", projectId: "project-2" }),
|
||||
].map((t) => [t.id, t]),
|
||||
),
|
||||
} as unknown as T3Host;
|
||||
expect(projectThreads(host, "project-1").map((row) => [row.threadId, row.phase, row.settled])).toEqual([
|
||||
["new", "running", false],
|
||||
["old", "completed", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 3 A: Quiet additions</title><style>
|
||||
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
|
||||
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
|
||||
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
|
||||
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
|
||||
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
|
||||
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||
.row { display:flex; align-items:center; gap:6px; }
|
||||
.name { font-weight:600; font-size:14px; line-height:1.2; }
|
||||
.meta { font-size:11px; color:var(--c, var(--dim)); font-weight:600; }
|
||||
.line { font-size:12px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
|
||||
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
|
||||
.big { display:block; text-align:center; border-radius:22px; padding:8px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||
.r { margin-left:auto; white-space:nowrap; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
|
||||
.msg { font-size:12px; color:#ddd; padding:2px 6px 6px; line-height:1.3; }
|
||||
.icon { display:inline-block; width:14px; text-align:center; color:var(--work); font-weight:700; }
|
||||
</style></head><body><div class="grid">
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span>3</span><span>9:41</span></div>
|
||||
<div class="card" style="--c:var(--work)"><div class="meta">Mac mini · wristcode</div><div class="name">Phase 3 screens</div><div class="line"><span class="icon">›_</span><span class="mono">bun test</span></div></div>
|
||||
<div class="card" style="--c:var(--work)"><div class="meta">Desktop · Panama</div><div class="name">Fix hyprlock crash</div><div class="line"><span class="icon">✎</span>Editing <span class="mono">lock.qml</span></div></div>
|
||||
</div></div><figcaption><b>Working card.</b> The agent's last message is replaced by what it is doing right now: a command, a file it is editing, a search, or Thinking.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span></span><span>9:41</span></div>
|
||||
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · 2h ago</div><div class="name" style="color:var(--dim)">Status screens</div></div>
|
||||
<div class="card" style="--c:var(--done);opacity:.8"><div class="row"><span class="name" style="font-size:13px;color:var(--done)">4 more</span><span class="r" style="color:var(--done)">›</span></div></div>
|
||||
<div class="card" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">All threads</span><span class="r meta">›</span></div></div>
|
||||
</div></div><figcaption><b>All threads.</b> One row at the very bottom of the feed. It opens hosts, then projects, then that project's threads, settled ones included, newest first.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ wristcode</span><span>9:41</span></div>
|
||||
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name" style="font-size:13px">Phase 3 screens</span></div>
|
||||
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Phase 2 switches</span><span class="r meta">1d</span></div>
|
||||
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Gear icon fix</span><span class="r meta">2d</span></div>
|
||||
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Recent window</span><span class="r meta">2d</span></div>
|
||||
</div></div><figcaption><b>A project's threads.</b> One line each, with a status dot and age. Tapping opens the usual thread screen.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||
<div class="msg">Delegated the watch screens to the Mac mini. Waiting on it…</div>
|
||||
<div class="label">Subagents</div>
|
||||
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name" style="font-size:13px">Watch Phase 3</span><span class="r meta" style="--c:var(--work)">working</span></div>
|
||||
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Read T3 contracts</span><span class="r meta">done</span></div>
|
||||
</div></div><figcaption><b>Subagents.</b> A section under the messages, above Reply. Each row opens that subagent's thread.</figcaption></figure>
|
||||
</div></body></html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 3 B: Activity forward</title><style>
|
||||
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
|
||||
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
|
||||
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
|
||||
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
|
||||
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
|
||||
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||
.row { display:flex; align-items:center; gap:6px; }
|
||||
.name { font-weight:600; font-size:14px; line-height:1.2; }
|
||||
.meta { font-size:11px; color:var(--c, var(--dim)); font-weight:600; }
|
||||
.line { font-size:12px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
|
||||
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
|
||||
.big { display:block; text-align:center; border-radius:22px; padding:8px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||
.r { margin-left:auto; white-space:nowrap; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
|
||||
.msg { font-size:12px; color:#ddd; padding:2px 6px 6px; line-height:1.3; }
|
||||
.icon { display:inline-block; width:14px; text-align:center; color:var(--work); font-weight:700; }
|
||||
</style></head><body><div class="grid">
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span>3</span><span>9:41</span></div>
|
||||
<div class="card" style="--c:var(--work)"><div class="meta">Mac mini · wristcode</div><div class="name">Phase 3 screens</div><div class="row" style="margin-top:4px;background:#0f2523;border-radius:9px;padding:4px 7px"><span class="mono" style="color:var(--work)">bun test</span><span class="r" style="font-size:11px;color:var(--work)">42s</span></div><div class="line">2 subagents working</div></div>
|
||||
</div></div><figcaption><b>Working card.</b> The current step sits in its own tinted strip with how long it has run. A second line counts working subagents.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ All threads</span><span>9:41</span></div>
|
||||
<div class="card" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">wristcode</span><span class="r meta">12</span></div><div class="line" style="margin-top:1px">Mac mini · 1 working</div></div>
|
||||
<div class="card" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">Panama</span><span class="r meta">31</span></div><div class="line" style="margin-top:1px">Desktop</div></div>
|
||||
<div class="card" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">APSCA</span><span class="r meta">8</span></div><div class="line" style="margin-top:1px">Laptop · offline</div></div>
|
||||
</div></div><figcaption><b>All threads.</b> A list button joins + at the top right. It opens every project across hosts in one list, busiest first, skipping the host step.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||
<div class="card" style="--c:var(--work)"><div class="meta">Working · 4m</div><div class="name">Phase 3 screens</div><div class="row" style="margin-top:5px;gap:4px"><span style="font-size:11px;font-weight:700;background:#0f2523;color:var(--work);border-radius:8px;padding:2px 7px">2 subagents ›</span></div></div>
|
||||
<div class="msg">Delegated the watch screens to the Mac mini. Waiting on it…</div>
|
||||
</div></div><figcaption><b>Thread header.</b> A chip under the title counts subagents and opens a list of them. It only appears when the thread has any.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Subagents</span><span>9:41</span></div>
|
||||
<div class="card" style="--c:var(--work)"><div class="name" style="font-size:13px">Watch Phase 3</div><div class="line"><span class="mono">xcodebuild test</span></div></div>
|
||||
<div class="card" style="--c:var(--done)"><div class="name" style="font-size:13px;color:var(--dim)">Read T3 contracts</div><div class="line">Done 3m ago</div></div>
|
||||
</div></div><figcaption><b>Subagents list.</b> Each subagent shows its own activity line, so you can follow it without opening it.</figcaption></figure>
|
||||
</div></body></html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 3 C: Timeline</title><style>
|
||||
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
|
||||
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
|
||||
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
|
||||
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
|
||||
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
|
||||
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||
.row { display:flex; align-items:center; gap:6px; }
|
||||
.name { font-weight:600; font-size:14px; line-height:1.2; }
|
||||
.meta { font-size:11px; color:var(--c, var(--dim)); font-weight:600; }
|
||||
.line { font-size:12px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
|
||||
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
|
||||
.big { display:block; text-align:center; border-radius:22px; padding:8px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||
.r { margin-left:auto; white-space:nowrap; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
|
||||
.msg { font-size:12px; color:#ddd; padding:2px 6px 6px; line-height:1.3; }
|
||||
.icon { display:inline-block; width:14px; text-align:center; color:var(--work); font-weight:700; }
|
||||
</style></head><body><div class="grid">
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span>3</span><span>9:41</span></div>
|
||||
<div class="card" style="--c:var(--work)"><div class="meta">Mac mini · wristcode</div><div class="name">Phase 3 screens</div>
|
||||
<div class="line mono" style="color:var(--done)">✓ Edited Models.swift</div><div class="line mono" style="color:var(--done);margin-top:1px">✓ Read api.md</div><div class="line mono" style="color:var(--work);margin-top:1px">› bun test</div></div>
|
||||
</div></div><figcaption><b>Working card.</b> The last three steps as a tiny log, finished ones dim, the current one in teal. More to read, and the card is taller.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ All threads</span><span>9:41</span></div>
|
||||
<div class="label">Today</div>
|
||||
<div class="card"><div class="meta">Mac mini · wristcode</div><div class="name" style="font-size:13px">Phase 3 screens</div></div>
|
||||
<div class="label">Yesterday</div>
|
||||
<div class="card"><div class="meta">Desktop · Panama</div><div class="name" style="font-size:13px;color:var(--dim)">Fix hyprlock crash</div></div>
|
||||
</div></div><figcaption><b>All threads.</b> One long list by day, every host and project mixed, newest first. Opened from a row under the feed. Simple, but long.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||
<div class="msg">I'll split this into two subagents.</div>
|
||||
<div class="card" style="--c:var(--work);margin-left:12px"><div class="meta" style="--c:var(--work)">↳ Subagent · working</div><div class="name" style="font-size:13px">Watch Phase 3</div></div>
|
||||
<div class="card" style="--c:var(--done);margin-left:12px"><div class="meta">↳ Subagent · done</div><div class="name" style="font-size:13px;color:var(--dim)">Read T3 contracts</div></div>
|
||||
</div></div><figcaption><b>Subagents in the timeline.</b> Each subagent shows where it was started among the messages, indented. Tap to open it.</figcaption></figure>
|
||||
|
||||
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||
<span class="big" style="background:var(--card);color:var(--dim)">Load earlier</span>
|
||||
<div class="msg" style="background:#1c1c1e;border-radius:12px;padding:6px 8px;margin:0 0 5px 20px">Run the tests</div>
|
||||
<div class="msg">All <b>48</b> pass. The fix is in <span class="mono" style="background:#222;border-radius:4px;padding:0 3px">host.ts</span>:<br>• bounded retry<br>• 3 s timeout</div>
|
||||
</div></div><figcaption><b>Same in every direction.</b> Load earlier at the top of a thread, and markdown: bold, inline code and lists.</figcaption></figure>
|
||||
</div></body></html>
|
||||
Reference in new issue
Block a user