Gateway: activity line, thread history paging, a project's threads, and a thread's subagents
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 21s

This commit is contained in:
gib committed 2026-11-02 12:42:44 -05:00
1 parent 6bc9cac6ca
commit 52210d0223
11 files changed
+474 -19

No files matched your search

+34 -2
View File
@@ -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
```
+20
View File
@@ -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);
+2 -1
View File
@@ -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 (
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+56 -1
View File
@@ -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],
]);
});
});
+47
View File
@@ -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>
+42
View File
@@ -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>
+46
View File
@@ -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>