From b08fd9bdb1350c34543f14c87bcab8989af3d76b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 2 Nov 2026 13:39:49 -0500 Subject: [PATCH] Gateway: changed files, search across hosts, a richer complication summary, and question options in pushes --- docs/api.md | 41 +++++++++++-- gateway/src/api.ts | 11 +++- gateway/src/contract.ts | 49 ++++++++++++++- gateway/src/notify.ts | 20 +++++- gateway/src/t3/host.ts | 12 ++++ gateway/src/t3/types.ts | 14 ++++- gateway/src/views.ts | 101 ++++++++++++++++++++++++++++++- gateway/test/notify.test.ts | 37 +++++++++++ gateway/test/views.test.ts | 69 +++++++++++++++++++-- mocks/phase4/a-changes.html | 46 ++++++++++++++ mocks/phase4/b-search.html | 42 +++++++++++++ mocks/phase4/c-complication.html | 39 ++++++++++++ mocks/phase4/d-notification.html | 38 ++++++++++++ 13 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 mocks/phase4/a-changes.html create mode 100644 mocks/phase4/b-search.html create mode 100644 mocks/phase4/c-complication.html create mode 100644 mocks/phase4/d-notification.html diff --git a/docs/api.md b/docs/api.md index bee4cbb..9e066e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -78,6 +78,7 @@ FeedItem { "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, + "changes": { "files": 3, "additions": 42, "deletions": 8 } | null, "request": PendingRequest | null, "via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null } @@ -99,6 +100,9 @@ directory), the search, the subagent's title or the tool's name, trimmed to abou 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. +`changes` totals the files the thread's latest run changed. It is null when the run changed no +files, and, like `lastMessage`, until the gateway has read the thread. + ``` PendingRequest { "id": "...", @@ -120,13 +124,15 @@ 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, - "earlierCursor": "w12" | null, "subagents": [ThreadRow] } + "earlierCursor": "w12" | null, "subagents": [ThreadRow], "changedFiles": [ChangedFile] } +ChangedFile { "path": "gateway/Containerfile", "name": "Containerfile", "additions": 31, "deletions": 0 } 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. `subagents` lists the +`messages` holds the last 20, oldest first, each trimmed to 2000 characters. `changedFiles` lists +the files the latest run changed, most lines changed first. `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. @@ -203,16 +209,36 @@ Thread detail fills `request` and `via` the same way as the feed when a subagent ## Summary -The counts behind the watch face complication. It reads no thread pages, so it is cheap to call. +What the watch face complication and Smart Stack widget show. It reads at most two threads, so it +is cheap to call. ``` GET /v1/summary 200 { "needsYou": 1, "working": 3, "hostsOffline": 0, - "next": { "hostId": "...", "threadId": "...", "title": "Receipt OCR", "hostLabel": "Mac mini" } } + "next": { "hostId": "...", "threadId": "...", "title": "Receipt OCR", "hostLabel": "Mac mini", + "request": "bun test --filter ocr" | null }, + "current": { "hostId": "...", "threadId": "...", "title": "Phase 3 screens", "activity": Activity | null } } ``` -`next` is the thread that has waited longest for you, or null. +`next` is the thread that has waited longest for you, or null. Its `request` is the command it wants +to run or its first question, trimmed to about 80 characters. `current` is the working thread that +changed most recently, or null when nothing is working. + +## Search + +``` +GET /v1/search?q=containerfile + +200 { "hits": [SearchHit], "unsearched": ["Laptop"] } +SearchHit { "thread": ThreadRow, "projectTitle": "wristcode", "hostLabel": "Mac mini", + "snippet": "…rebuild from the Containerfile and…" | null } +``` + +Searches thread titles and messages on every online host at once, up to 20 hits per host, newest +match first. `snippet` is the text around a match in a message, or null when only the title +matched. `unsearched` names the hosts that were offline, failed, or took over 5 seconds. A query +needs at least two characters. ## Browse @@ -258,7 +284,10 @@ the host's, with its options, such as reasoning effort. A picked model the host The gateway pushes when a thread starts waiting for you, finishes, or fails. Each push has an `aps.category` and the custom keys `hostId`, `threadId` (absent on `host` pushes), and, for -approvals and questions, `requestId`. A `test` push carries no custom keys. A subagent's push is titled with the thread that started it, +approvals and questions, `requestId`. An `input` push that asks one single-choice question also +carries `question: { id, options: [{ label, value }], allowCustomAnswer }`, with at most 4 options, +so the notification can offer them as buttons. Answer with `{ "": "" }` on the respond +route. A `test` push carries no custom keys. A subagent's push is titled with the thread that started it, its body starts with "Subagent :", and its `hostId` and `threadId` are the subagent's. | Category | When | Actions the watch registers | diff --git a/gateway/src/api.ts b/gateway/src/api.ts index b8867be..ad363c6 100644 --- a/gateway/src/api.ts +++ b/gateway/src/api.ts @@ -37,6 +37,7 @@ import { hostSummary, loadHistory, projectThreads, + searchThreads, type SummaryCache, } from "./views.ts"; @@ -259,7 +260,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }, "/v1/summary": { - GET: authed(() => json(buildSummary(hub.list()))), + GET: authed(async () => json(await buildSummary(hub.list(), cache))), }, "/v1/projects": { @@ -280,6 +281,14 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }), }, + "/v1/search": { + GET: authed(async (req) => { + const query = (new URL(req.url).searchParams.get("q") ?? "").trim().slice(0, 200); + if (query.length < 2) throw new HttpError(400, "Search for at least two letters."); + return json(await searchThreads(hub.list(), query)); + }), + }, + "/v1/projects/:hostId/:projectId/threads": { GET: authed<"/v1/projects/:hostId/:projectId/threads">((req) => { const host = findHost(req.params.hostId); diff --git a/gateway/src/contract.ts b/gateway/src/contract.ts index cf0d32f..c90f48c 100644 --- a/gateway/src/contract.ts +++ b/gateway/src/contract.ts @@ -118,6 +118,8 @@ export interface FeedItem { lastMessage: string | null; /** What a working thread is doing right now. Null when nothing is in progress or not read yet. */ activity: Activity | null; + /** What the latest run changed, in total. Null when it changed no files or is not read yet. */ + changes: ChangeTotals | null; request: PendingRequest | null; /** * Set when `request` comes from a subagent this thread delegated to, rather than the thread @@ -135,6 +137,35 @@ export interface Activity { startedAt: string | null; } +export interface ChangeTotals { + files: number; + additions: number; + deletions: number; +} + +/** A file the latest run changed. `name` is the last part of `path`. */ +export interface ChangedFile { + path: string; + name: string; + additions: number; + deletions: number; +} + +/** A thread search hit: the thread, and where the words matched when it was inside a message. */ +export interface SearchHit { + thread: ThreadRow; + projectTitle: string; + hostLabel: string; + /** Text around the match in a message, or null when only the title matched. */ + snippet: string | null; +} + +export interface SearchResponse { + hits: SearchHit[]; + /** Labels of hosts that were offline or failed, so their threads were not searched. */ + unsearched: string[]; +} + /** A thread in a list: a project's threads, or a thread's subagents. */ export interface ThreadRow { hostId: string; @@ -162,7 +193,16 @@ export interface Summary { needsYou: number; working: number; /** The thread that has waited longest for you, if any. */ - next: { hostId: string; threadId: string; title: string; hostLabel: string } | null; + next: { + hostId: string; + threadId: string; + title: string; + hostLabel: string; + /** The command it wants to run or its first question, trimmed. Null until the gateway has read it. */ + request: string | null; + } | null; + /** The working thread updated most recently, with what it is doing. Null when nothing is working. */ + current: { hostId: string; threadId: string; title: string; activity: Activity | null } | null; hostsOffline: number; } @@ -208,6 +248,8 @@ export interface ThreadDetail { earlierCursor: string | null; /** Subagents and delegated threads this thread started, newest first. */ subagents: ThreadRow[]; + /** The files the latest run changed, most lines changed first. */ + changedFiles: ChangedFile[]; } export interface ThreadModeRequest { @@ -290,6 +332,11 @@ export interface PushPayload { /** Absent on `host` pushes, which are about a host rather than a thread. */ threadId?: string; requestId?: string; + /** + * On `input` pushes that ask one single-choice question: its options, so the notification can + * offer each as a button. `allowCustomAnswer` adds a spoken answer. + */ + question?: { id: string; options: Array<{ label: string; value: string }>; allowCustomAnswer: boolean }; } /** Notification categories the watch registers actions for. */ diff --git a/gateway/src/notify.ts b/gateway/src/notify.ts index d810993..9706ac8 100644 --- a/gateway/src/notify.ts +++ b/gateway/src/notify.ts @@ -102,12 +102,30 @@ export class Notifier { body: parent ? `Subagent ${thread.title}: ${text}` : text, category: category(phase), fromSubagent: parent !== undefined, - data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}) }, + data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}), ...pushQuestion(phase, summary) }, collapseId: `${host.id}:${thread.id}`.slice(0, 64), }); } } +/** + * A lone single-choice question's options, so the notification can offer them as buttons. Labels + * are trimmed and the list capped to keep the payload under APNs' 4 KB. + */ +function pushQuestion(phase: Phase, summary: ThreadSummary | undefined): Pick<PushPayload, "question"> { + const questions = summary?.request?.questions ?? []; + const question = questions[0]; + if (phase !== "waiting_for_input" || questions.length !== 1 || !question || question.multiSelect) return {}; + if (question.options.length === 0 && !question.allowCustomAnswer) return {}; + return { + question: { + id: question.id, + options: question.options.slice(0, 4).map(({ label, value }) => ({ label: trimText(label, 40), value: value.slice(0, 200) })), + allowCustomAnswer: question.allowCustomAnswer, + }, + }; +} + function category(phase: Phase): PushCategory { switch (phase) { case "waiting_for_approval": diff --git a/gateway/src/t3/host.ts b/gateway/src/t3/host.ts index a848208..130296d 100644 --- a/gateway/src/t3/host.ts +++ b/gateway/src/t3/host.ts @@ -8,6 +8,7 @@ import { type Command, type EnvironmentDescriptor, type HistoryPage, + type SearchMatch, type LaunchThreadInput, type Project, type ServerConfig, @@ -176,6 +177,17 @@ export class T3Host { return this.http<BoundedThread>(`/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`); } + /** Threads whose titles or messages match `query`, best first. Fails after 5 s. */ + async search(query: string): Promise<SearchMatch[]> { + const { matches } = await Promise.race([ + this.requireSocket().call<{ matches: SearchMatch[] }>("orchestration.searchThreads", { query, limit: 20 }), + Bun.sleep(5_000).then(() => { + throw new HostError(`${this.label} took too long to search`, 504); + }), + ]); + return matches; + } + /** 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" }); diff --git a/gateway/src/t3/types.ts b/gateway/src/t3/types.ts index f14df53..79f6dea 100644 --- a/gateway/src/t3/types.ts +++ b/gateway/src/t3/types.ts @@ -123,8 +123,11 @@ export interface TurnItem { input?: unknown; /** Set on `command_execution` items that are monitors rather than commands. */ monitor?: boolean; - /** The file, on `file_change`. */ + runId?: string | null; + /** The file, on `file_change`, with its line counts. */ fileName?: string; + additions?: number; + deletions?: number; /** The search, on `file_search`. */ pattern?: string; /** The tool, on `dynamic_tool`. */ @@ -167,6 +170,15 @@ export interface BoundedThread { }; } +/** One hit from `orchestration.searchThreads`, best first. */ +export interface SearchMatch { + threadId: string; + projectId: string; + source: Open<"user" | "assistant">; + snippet: string; + messageCreatedAt: string | null; +} + /** One page of `GET /api/orchestration/threads/:threadId/history`, older than the cursor. */ export interface HistoryPage { items: Array<{ item: TurnItem }>; diff --git a/gateway/src/views.ts b/gateway/src/views.ts index 4b58821..2269f59 100644 --- a/gateway/src/views.ts +++ b/gateway/src/views.ts @@ -7,6 +7,7 @@ import { resolvePhase, threadPhase, type Phase } from "./awareness.ts"; import type { Activity, AgentProvider, + ChangedFile, Feed, FeedItem, HostStatusDetail, @@ -19,6 +20,7 @@ import type { ThreadDetail, ThreadRow, HistoryResponse, + SearchResponse, } from "./contract.ts"; import type { T3Host } from "./t3/host.ts"; import { @@ -46,6 +48,8 @@ export interface ThreadSummary { lastAssistantMessage: string | null; request: PendingRequest | null; activity: Activity | null; + /** The files the latest run in the window changed. */ + changedFiles: ChangedFile[]; /** The history route's cursor for messages before `messages`. */ earlierCursor: string | null; } @@ -70,6 +74,7 @@ export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSu lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null, request: pendingRequest(thread, turnItems), activity: currentActivity(turnItems), + changedFiles: latestChanges(turnItems), earlierCursor: windowCursor(start, bounded), }; } @@ -128,6 +133,21 @@ export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: str return { messages: [], earlierCursor: next && `h${next}` }; } +/** The files the latest run changed, summed per file, most lines changed first. */ +export function latestChanges(turnItems: TurnItem[]): ChangedFile[] { + const runId = turnItems.findLast((item) => item.runId)?.runId; + if (!runId) return []; + const files = new Map<string, ChangedFile>(); + for (const item of turnItems) { + if (item.type !== "file_change" || item.runId !== runId || !item.fileName) continue; + const file = files.get(item.fileName) ?? { path: item.fileName, name: item.fileName.split("/").pop()!, additions: 0, deletions: 0 }; + file.additions += item.additions ?? 0; + file.deletions += item.deletions ?? 0; + files.set(item.fileName, file); + } + return [...files.values()].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions)); +} + /** The newest step still in progress, said briefly. Monitors are left out, as they run for hours. */ export function currentActivity(turnItems: TurnItem[]): Activity | null { for (let i = turnItems.length - 1; i >= 0; i--) { @@ -341,6 +361,13 @@ function feedItem( updatedAt: thread.updatedAt, lastMessage: summary?.lastAssistantMessage ?? null, activity: summary?.activity ?? null, + changes: summary?.changedFiles.length + ? { + files: summary.changedFiles.length, + additions: summary.changedFiles.reduce((sum, file) => sum + file.additions, 0), + deletions: summary.changedFiles.reduce((sum, file) => sum + file.deletions, 0), + } + : null, request: (via ? via.summary : summary)?.request ?? null, via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null, }; @@ -362,25 +389,55 @@ function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShel } /** Counts for the complication, from thread shells alone. */ -export function buildSummary(hosts: T3Host[]): Summary { +/** + * Counts for the complication, plus the longest waiting request and the busiest working thread. + * Reads at most those two threads, waiting briefly as the feed does. + */ +export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promise<Summary> { let needsYou = 0; let working = 0; let next: { host: T3Host; thread: ThreadShell } | null = null; + let current: { host: T3Host; thread: ThreadShell } | null = null; for (const host of hosts) { for (const thread of host.threads.values()) { if (thread.archivedAt || thread.deletedAt) continue; const phase = threadPhase(thread); - if (phase === "starting" || phase === "running") working++; + if (phase === "starting" || phase === "running") { + working++; + if (!current || thread.updatedAt > current.thread.updatedAt) current = { host, thread }; + } if (phase !== "waiting_for_approval" && phase !== "waiting_for_input") continue; needsYou++; const waitingSince = thread.pendingRuntimeRequest?.createdAt ?? ""; if (!next || waitingSince < (next.thread.pendingRuntimeRequest?.createdAt ?? "")) next = { host, thread }; } } + const read = [next, current].filter((pick) => pick !== null); + const missing = read.filter(({ host, thread }) => !cache.peek(host, thread)); + if (missing.length > 0) { + await Promise.race([ + Promise.all(missing.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined))), + Bun.sleep(SUMMARY_WAIT_MS), + ]); + } + const request = next && cache.peek(next.host, next.thread)?.request; + const asked = request?.prompt ?? request?.questions[0]?.question; return { needsYou, working, - next: next && { hostId: next.host.id, threadId: next.thread.id, title: next.thread.title, hostLabel: next.host.label }, + next: next && { + hostId: next.host.id, + threadId: next.thread.id, + title: next.thread.title, + hostLabel: next.host.label, + request: asked ? trimText(asked, ACTIVITY_CHARS) : null, + }, + current: current && { + hostId: current.host.id, + threadId: current.thread.id, + title: current.thread.title, + activity: cache.peek(current.host, current.thread)?.activity ?? null, + }, hostsOffline: hosts.filter((host) => host.status !== "online").length, }; } @@ -464,6 +521,7 @@ export async function buildThreadDetail( askFirst: thread.runtimeMode === ASK_FIRST, earlierCursor: summary.earlierCursor, subagents: subagents(hosts, host, thread), + changedFiles: summary.changedFiles, }; } @@ -497,6 +555,43 @@ function subagents(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Th return rows.sort(newestFirst); } +/** + * Searches every online host at once and merges the hits, newest match first. Hosts that are + * offline or fail are named in `unsearched`. + */ +export async function searchThreads(hosts: T3Host[], query: string): Promise<SearchResponse> { + const results = await Promise.all( + hosts.map(async (host) => { + if (host.status !== "online") return null; + try { + return { host, matches: await host.search(query) }; + } catch { + return null; + } + }), + ); + const hits = results.flatMap((result) => + (result?.matches ?? []).flatMap((match) => { + const thread = result!.host.threads.get(match.threadId); + if (!thread || !isLive(thread)) return []; + const snippet = match.snippet.trim(); + return [ + { + thread: threadRow(result!.host, thread), + projectTitle: result!.host.projects.get(thread.projectId)?.title ?? "", + hostLabel: result!.host.label, + snippet: snippet && snippet !== thread.title ? trimText(snippet, 160) : null, + at: match.messageCreatedAt ?? thread.updatedAt, + }, + ]; + }), + ); + return { + hits: hits.sort((a, b) => b.at.localeCompare(a.at)).map(({ at: _, ...hit }) => hit), + unsearched: hosts.filter((host, i) => results[i] === null).map((host) => host.label), + }; +} + /** A project's newest 50 threads, settled ones included. Subagents are listed under their parents. */ export function projectThreads(host: T3Host, projectId: string): ThreadRow[] { return [...host.threads.values()] diff --git a/gateway/test/notify.test.ts b/gateway/test/notify.test.ts index 28f9272..7f1002d 100644 --- a/gateway/test/notify.test.ts +++ b/gateway/test/notify.test.ts @@ -108,3 +108,40 @@ describe("Notifier", () => { ]); }); }); + +describe("question pushes", () => { + it("carry a lone single-choice question's options for the notification's buttons", async () => { + const asking = shell({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } }); + const question = { + id: "base", + header: "Image", + question: "Which base image?", + options: [ + { label: "bun:1-alpine", description: "" }, + { label: "bun:1-debian", description: "", value: "debian" }, + ], + allowCustomAnswer: true, + }; + const reading = { + ...host, + bounded: () => + Promise.resolve({ + snapshotSequence: 1, + projection: { thread: asking, messages: [], turnItems: [{ type: "user_input_request", requestId: "q1", questions: [question] }] }, + }), + } as unknown as T3Host; + const pushes: Push[] = []; + const notifier = new Notifier(async (push) => void pushes.push(push), new SummaryCache()); + notifier.onThreadChange({ host: reading, previous: undefined, next: shell(), initial: true }); + notifier.onThreadChange({ host: reading, previous: shell(), next: asking, initial: false }); + await Bun.sleep(5); + expect(pushes[0]?.data.question).toEqual({ + id: "base", + options: [ + { label: "bun:1-alpine", value: "bun:1-alpine" }, + { label: "bun:1-debian", value: "debian" }, + ], + allowCustomAnswer: true, + }); + }); +}); diff --git a/gateway/test/views.test.ts b/gateway/test/views.test.ts index c1d1bd4..61c6705 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, currentActivity, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts"; +import { agentProviders, buildFeed, currentActivity, latestChanges, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts"; const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({ id: "thread-1", @@ -107,10 +107,16 @@ describe("summarize", () => { describe("buildSummary", () => { const at = (minute: number) => `2026-11-01T09:${String(minute).padStart(2, "0")}:00.000Z`; const host = (label: string, status: string, threads: ThreadShell[]) => - ({ id: label, label, status, threads: new Map(threads.map((t) => [t.id, t])) }) as unknown as T3Host; + ({ + id: label, + label, + status, + threads: new Map(threads.map((t) => [t.id, t])), + bounded: () => Promise.reject(new Error("not read")), + }) as unknown as T3Host; - it("counts waiting and working threads and names the one waiting longest", () => { - const summary = buildSummary([ + it("counts waiting and working threads and names the one waiting longest", async () => { + const summary = await buildSummary([ host("Mac mini", "online", [ thread({ id: "a", title: "Newer ask", pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: at(30) } }), thread({ id: "b", status: "running" }), @@ -120,11 +126,12 @@ describe("buildSummary", () => { host("LittleMan", "offline", [ thread({ id: "e", title: "Older ask", pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: at(5) } }), ]), - ]); + ], new SummaryCache()); expect(summary).toEqual({ needsYou: 2, working: 1, - next: { hostId: "LittleMan", threadId: "e", title: "Older ask", hostLabel: "LittleMan" }, + next: { hostId: "LittleMan", threadId: "e", title: "Older ask", hostLabel: "LittleMan", request: null }, + current: { hostId: "Mac mini", threadId: "b", title: "Receipt OCR", activity: null }, hostsOffline: 1, }); }); @@ -286,3 +293,53 @@ describe("projectThreads", () => { ]); }); }); + +describe("latestChanges", () => { + it("sums each file the latest run changed, most lines first", () => { + const change = (runId: string, fileName: string, additions: number, deletions: number) => + ({ type: "file_change", runId, fileName, additions, deletions }); + expect( + latestChanges([ + change("run-1", "old.ts", 50, 0), + change("run-2", "gateway/compose.yml", 3, 2), + change("run-2", "gateway/Containerfile", 31, 0), + change("run-2", "gateway/compose.yml", 6, 4), + { type: "assistant_message", runId: "run-2" }, + ]), + ).toEqual([ + { path: "gateway/Containerfile", name: "Containerfile", additions: 31, deletions: 0 }, + { path: "gateway/compose.yml", name: "compose.yml", additions: 9, deletions: 6 }, + ]); + }); +}); + +describe("searchThreads", () => { + it("merges every host's hits newest first and names hosts it could not search", async () => { + const searching = (label: string, status: string, threads: ThreadShell[], matches: object[]) => + ({ + id: label, + label, + status, + projects: new Map([["project-1", { title: "wristcode" }]]), + threads: new Map(threads.map((t) => [t.id, t])), + search: () => Promise.resolve(matches), + }) as unknown as T3Host; + const match = (threadId: string, snippet: string, at: string) => ({ threadId, projectId: "project-1", source: "assistant", snippet, messageCreatedAt: at }); + const result = await searchThreads( + [ + searching("Desktop", "online", [thread({ id: "a", title: "Gateway Containerfile" })], [match("a", "Gateway Containerfile", "2026-11-01T08:00:00.000Z")]), + searching("Mac mini", "online", [thread({ id: "b", title: "Jellyfin" }), thread({ id: "gone", deletedAt: "x" })], [ + match("b", "rebuild from the Containerfile", "2026-11-01T09:00:00.000Z"), + match("gone", "Containerfile", "2026-11-01T10:00:00.000Z"), + ]), + searching("Laptop", "offline", [], []), + ], + "containerfile", + ); + expect(result.hits.map((hit) => [hit.hostLabel, hit.thread.threadId, hit.snippet])).toEqual([ + ["Mac mini", "b", "rebuild from the Containerfile"], + ["Desktop", "a", null], + ]); + expect(result.unsearched).toEqual(["Laptop"]); + }); +}); diff --git a/mocks/phase4/a-changes.html b/mocks/phase4/a-changes.html new file mode 100644 index 0000000..219c5cc --- /dev/null +++ b/mocks/phase4/a-changes.html @@ -0,0 +1,46 @@ +<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: What changed
+

Option 1: a line on the card, the files on the thread screen

+
Recent9:41
+
Mac mini · 4m ago
Gateway Containerfile
+42 −8 · 3 files
+
Desktop · 1h ago
Explain the hub
+
Recent card. One line of totals under the title. A thread that changed nothing, like a question, shows no line.
+
‹ Thread9:41
+
Done. The image builds and the healthcheck passes.
+
Changed
+
Containerfile+31 −0
+
compose.yml+9 −6
+
health.ts+2 −2
+
Thread screen. A Changed section under the messages lists each file of the latest run with its counts. Read only.
+
Recent9:41
+
Mac mini · 4m ago
Gateway Containerfile
● 3 files changed
+
Lighter card line. Only the file count, no line counts. Same thread screen as the middle.
+

Option 2: thread screen only

+
‹ Thread9:41
+
Done. The image builds and the healthcheck passes.
+
3 files changed+42 −8 ›
+Reply +
One row on the thread screen. Cards stay as they are. The row opens the file list.
+
diff --git a/mocks/phase4/b-search.html b/mocks/phase4/b-search.html new file mode 100644 index 0000000..b1f1cbd --- /dev/null +++ b/mocks/phase4/b-search.html @@ -0,0 +1,42 @@ +WristCode Phase 4: Search
+

Option 1: a Search row at the top of All threads

+
‹ All threads9:41
+
⌕Search🎙
+
Desktop
+
Mac mini
+
All threads. A search field above the hosts. Tapping it opens dictation or the keyboard, as watchOS does for any text field.
+
‹ "containerfile"9:41
+
Mac mini · wristcode
Gateway Containerfile
+
server-gib · homelab
Jellyfin image update
…rebuild from the Containerfile and…
+
Results. Every host searched at once, best first. A match inside a message shows a snippet. Tapping opens the thread.
+
‹ "containerfile"9:41
+
No threads match.

Laptop is offline, so its threads were not searched.
+
No results. Says which hosts it could not reach.
+

Option 2: a search button on the feed

+
⚙⌕+
+
2
waiting on you
+
Feed toolbar. Search sits beside + and opens dictation straight away. One tap less, but the top right gets crowded.
+
diff --git a/mocks/phase4/c-complication.html b/mocks/phase4/c-complication.html new file mode 100644 index 0000000..4e80b67 --- /dev/null +++ b/mocks/phase4/c-complication.html @@ -0,0 +1,39 @@ +WristCode Phase 4: Complication
+

Watch face (rectangular slot)

+
9:41
+
● 2 waiting1 working
HomeLab Stats
brew install jq
+
A. Next request. The counts, then the thread that has waited longest and what it wants. Tap opens that thread.
+
9:41
+
Phase 3 screens · 42s
›_ bun test
● 2 waiting
+
B. Current activity. What the busiest working thread is doing now, with the waiting count under it.
+
9:41
+
● 2● 1● 4
HomeLab Stats
wants to run a command
+
C. Switching. Shows A while anything waits on you, B otherwise. Recent count added as a third dot.
+

Smart Stack

+
9:41
+
2 waiting1 working
HomeLab Stats
brew install jq
AllowDeny
+
Smart Stack widget. The same content as the chosen face option, larger. With A, Allow and Deny work right in the stack.
+
diff --git a/mocks/phase4/d-notification.html b/mocks/phase4/d-notification.html new file mode 100644 index 0000000..ad01227 --- /dev/null +++ b/mocks/phase4/d-notification.html @@ -0,0 +1,38 @@ +WristCode Phase 4: Answering from the notification
+

A question arrives

+
WRISTCODE
+
Gateway Containerfile
Which base image?
+bun:1-alpinebun:1-debianOpen +
A. Options as buttons. Each option is a button on the notification itself, then Open. A tap sends that answer.
+
WRISTCODE
+
Gateway Containerfile
Which base image?
+bun:1-alpinebun:1-debian🎙 Say an answer +
B. Options and a spoken answer. Like A, plus a dictated answer for questions that allow one. Open stays below.
+
WRISTCODE
+
Gateway Containerfile
2 questions
+AnswerOpen +
Several questions. In A and B, a request with more than one question shows Answer, which opens the app on the question screen.
+