diff --git a/docs/api.md b/docs/api.md index 0d7134a..abe3bcf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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= + +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 ``` diff --git a/gateway/src/api.ts b/gateway/src/api.ts index ad7e310..b8867be 100644 --- a/gateway/src/api.ts +++ b/gateway/src/api.ts @@ -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(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); diff --git a/gateway/src/awareness.ts b/gateway/src/awareness.ts index 47e1efa..d493ea5 100644 --- a/gateway/src/awareness.ts +++ b/gateway/src/awareness.ts @@ -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 ( diff --git a/gateway/src/contract.ts b/gateway/src/contract.ts index 52776de..cf0d32f 100644 --- a/gateway/src/contract.ts +++ b/gateway/src/contract.ts @@ -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 { diff --git a/gateway/src/t3/host.ts b/gateway/src/t3/host.ts index 9721518..a848208 100644 --- a/gateway/src/t3/host.ts +++ b/gateway/src/t3/host.ts @@ -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(`/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 { + const query = new URLSearchParams({ cursor, view: "conversation" }); + return this.http(`/api/orchestration/threads/${encodeURIComponent(threadId)}/history?${query}`); + } + /** The session's expiry, from `/api/auth/session`. */ async sessionExpiresAt(): Promise { const session = await this.http<{ expiresAt?: string }>("/api/auth/session"); diff --git a/gateway/src/t3/types.ts b/gateway/src/t3/types.ts index 3062da1..f14df53 100644 --- a/gateway/src/t3/types.ts +++ b/gateway/src/t3/types.ts @@ -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; diff --git a/gateway/src/views.ts b/gateway/src/views.ts index 821ab30..f230454 100644 --- a/gateway/src/views.ts +++ b/gateway/src/views.ts @@ -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): 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` means the bounded window's messages before index n, +// which the thread detail leaves out past its 20. `h` 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 { + 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 | 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(); + private readonly entries = new Map(); private readonly inflight = new Map>(); 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 { 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"; diff --git a/gateway/test/views.test.ts b/gateway/test/views.test.ts index 180c4f1..85b3498 100644 --- a/gateway/test/views.test.ts +++ b/gateway/test/views.test.ts @@ -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 => ({ 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], + ]); + }); +}); diff --git a/mocks/phase3/a-quiet.html b/mocks/phase3/a-quiet.html new file mode 100644 index 0000000..06e22a1 --- /dev/null +++ b/mocks/phase3/a-quiet.html @@ -0,0 +1,47 @@ +WristCode Phase 3 A: Quiet additions
+
39:41
+
Mac mini · wristcode
Phase 3 screens
›_bun test
+
Desktop · Panama
Fix hyprlock crash
✎Editing lock.qml
+
Working card. 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.
+ +
9:41
+
Desktop · 2h ago
Status screens
+
4 more›
+
All threads›
+
All threads. One row at the very bottom of the feed. It opens hosts, then projects, then that project's threads, settled ones included, newest first.
+ +
‹ wristcode9:41
+
Phase 3 screens
+
Phase 2 switches1d
+
Gear icon fix2d
+
Recent window2d
+
A project's threads. One line each, with a status dot and age. Tapping opens the usual thread screen.
+ +
‹ Thread9:41
+
Delegated the watch screens to the Mac mini. Waiting on it…
+
Subagents
+
Watch Phase 3working
+
Read T3 contractsdone
+
Subagents. A section under the messages, above Reply. Each row opens that subagent's thread.
+
diff --git a/mocks/phase3/b-activity.html b/mocks/phase3/b-activity.html new file mode 100644 index 0000000..7cbe4b7 --- /dev/null +++ b/mocks/phase3/b-activity.html @@ -0,0 +1,42 @@ +WristCode Phase 3 B: Activity forward
+
39:41
+
Mac mini · wristcode
Phase 3 screens
bun test42s
2 subagents working
+
Working card. The current step sits in its own tinted strip with how long it has run. A second line counts working subagents.
+ +
‹ All threads9:41
+
wristcode12
Mac mini · 1 working
+
Panama31
Desktop
+
APSCA8
Laptop · offline
+
All threads. A list button joins + at the top right. It opens every project across hosts in one list, busiest first, skipping the host step.
+ +
‹ Thread9:41
+
Working · 4m
Phase 3 screens
2 subagents ›
+
Delegated the watch screens to the Mac mini. Waiting on it…
+
Thread header. A chip under the title counts subagents and opens a list of them. It only appears when the thread has any.
+ +
‹ Subagents9:41
+
Watch Phase 3
xcodebuild test
+
Read T3 contracts
Done 3m ago
+
Subagents list. Each subagent shows its own activity line, so you can follow it without opening it.
+
diff --git a/mocks/phase3/c-timeline.html b/mocks/phase3/c-timeline.html new file mode 100644 index 0000000..7b2c28e --- /dev/null +++ b/mocks/phase3/c-timeline.html @@ -0,0 +1,46 @@ +WristCode Phase 3 C: Timeline
+
39:41
+
Mac mini · wristcode
Phase 3 screens
+
✓ Edited Models.swift
✓ Read api.md
› bun test
+
Working card. The last three steps as a tiny log, finished ones dim, the current one in teal. More to read, and the card is taller.
+ +
‹ All threads9:41
+
Today
+
Mac mini · wristcode
Phase 3 screens
+
Yesterday
+
Desktop · Panama
Fix hyprlock crash
+
All threads. One long list by day, every host and project mixed, newest first. Opened from a row under the feed. Simple, but long.
+ +
‹ Thread9:41
+
I'll split this into two subagents.
+
↳ Subagent · working
Watch Phase 3
+
↳ Subagent · done
Read T3 contracts
+
Subagents in the timeline. Each subagent shows where it was started among the messages, indented. Tap to open it.
+ +
‹ Thread9:41
+Load earlier +
Run the tests
+
All 48 pass. The fix is in host.ts:
• bounded retry
• 3 s timeout
+
Same in every direction. Load earlier at the top of a thread, and markdown: bold, inline code and lists.
+