Gateway: changed files, search across hosts, a richer complication summary, and question options in pushes
Gateway / check (push) Successful in 10s
Gateway / image (push) Successful in 23s

This commit is contained in:
gib committed 2026-11-02 13:39:49 -05:00
1 parent 45251b1a43
commit b08fd9bdb1
13 files changed
+500 -19

No files matched your search

+35 -6
View File
@@ -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 `{ "<id>": "<value>" }` 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 <title>:", and its `hostId` and `threadId` are the subagent's.
| Category | When | Actions the watch registers |
+10 -1
View File
@@ -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);
+48 -1
View File
@@ -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. */
+19 -1
View File
@@ -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":
+12
View File
@@ -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" });
+13 -1
View File
@@ -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 }>;
+98 -3
View File
@@ -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()]
+37
View File
@@ -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,
});
});
});
+63 -6
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, 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"]);
});
});
+46
View File
@@ -0,0 +1,46 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: What changed</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; 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:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>Option 1: a line on the card, the files on the thread screen</h3>
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="meta">Mac mini · 4m ago</div><div class="name" style="color:var(--dim)">Gateway Containerfile</div><div class="line mono"><span class="add">+42</span> <span class="del">−8</span> · 3 files</div></div>
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · 1h ago</div><div class="name" style="color:var(--dim)">Explain the hub</div></div>
</div></div><figcaption><b>Recent card.</b> One line of totals under the title. A thread that changed nothing, like a question, shows no line.</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="line" style="padding:0 6px 4px;color:#ddd">Done. The image builds and the healthcheck passes.</div>
<div class="label">Changed</div>
<div class="card row"><span class="mono">Containerfile</span><span class="r mono"><span class="add">+31</span> <span class="del">−0</span></span></div>
<div class="card row"><span class="mono">compose.yml</span><span class="r mono"><span class="add">+9</span> <span class="del">−6</span></span></div>
<div class="card row"><span class="mono">health.ts</span><span class="r mono"><span class="add">+2</span> <span class="del">−2</span></span></div>
</div></div><figcaption><b>Thread screen.</b> A Changed section under the messages lists each file of the latest run with its counts. Read only.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="meta">Mac mini · 4m ago</div><div class="name" style="color:var(--dim)">Gateway Containerfile</div><div class="line"><span class="add">●</span> 3 files changed</div></div>
</div></div><figcaption><b>Lighter card line.</b> Only the file count, no line counts. Same thread screen as the middle.</figcaption></figure>
<h3>Option 2: thread screen only</h3>
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="line" style="padding:0 6px 4px;color:#ddd">Done. The image builds and the healthcheck passes.</div>
<div class="card row"><span class="name" style="font-size:12px">3 files changed</span><span class="r mono"><span class="add">+42</span> <span class="del">−8</span> ›</span></div>
<span class="big" style="background:var(--card)">Reply</span>
</div></div><figcaption><b>One row on the thread screen.</b> Cards stay as they are. The row opens the file list.</figcaption></figure>
</div></body></html>
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Search</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; 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:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>Option 1: a Search row at the top of All threads</h3>
<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 row" style="background:#1f1f22"><span style="color:var(--dim)">⌕</span><span style="color:var(--dim)">Search</span><span class="r" style="color:var(--work)">🎙</span></div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Desktop</span></div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Mac mini</span></div>
</div></div><figcaption><b>All threads.</b> A search field above the hosts. Tapping it opens dictation or the keyboard, as watchOS does for any text field.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ "containerfile"</span><span>9:41</span></div>
<div class="card"><div class="meta">Mac mini · wristcode</div><div class="name">Gateway Containerfile</div></div>
<div class="card"><div class="meta">server-gib · homelab</div><div class="name" style="color:var(--dim)">Jellyfin image update</div><div class="line">…rebuild from the <b style="color:#fff">Containerfile</b> and…</div></div>
</div></div><figcaption><b>Results.</b> Every host searched at once, best first. A match inside a message shows a snippet. Tapping opens the thread.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ "containerfile"</span><span>9:41</span></div>
<div class="line" style="padding:20px 10px;text-align:center">No threads match.<br><br><span style="font-size:10.5px">Laptop is offline, so its threads were not searched.</span></div>
</div></div><figcaption><b>No results.</b> Says which hosts it could not reach.</figcaption></figure>
<h3>Option 2: a search button on the feed</h3>
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--dim)">⚙</span><span style="display:flex;gap:10px"><span>⌕</span><span>+</span></span></div>
<div style="font-size:30px;font-weight:700;color:var(--you);padding:0 8px">2</div><div class="line" style="padding:0 8px">waiting on you</div>
</div></div><figcaption><b>Feed toolbar.</b> Search sits beside + and opens dictation straight away. One tap less, but the top right gets crowded.</figcaption></figure>
</div></body></html>
+39
View File
@@ -0,0 +1,39 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Complication</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; 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:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>Watch face (rectangular slot)</h3>
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="row"><span class="meta" style="--c:var(--you)">● 2 waiting</span><span class="r meta">1 working</span></div><div class="name" style="font-size:12px;margin-top:2px">HomeLab Stats</div><div class="line mono" style="margin-top:1px">brew install jq</div></div>
</div></div><figcaption><b>A. Next request.</b> The counts, then the thread that has waited longest and what it wants. Tap opens that thread.</figcaption></figure>
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="meta" style="--c:var(--work)">Phase 3 screens · 42s</div><div class="line mono" style="color:#fff;margin-top:2px">›_ bun test</div><div class="meta" style="margin-top:3px;--c:var(--you)">● 2 waiting</div></div>
</div></div><figcaption><b>B. Current activity.</b> What the busiest working thread is doing now, with the waiting count under it.</figcaption></figure>
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="row"><span class="meta" style="--c:var(--you)">● 2</span><span class="meta" style="--c:var(--work)">● 1</span><span class="meta" style="--c:var(--done)">● 4</span></div><div class="name" style="font-size:12px;margin-top:3px">HomeLab Stats</div><div class="line">wants to run a command</div></div>
</div></div><figcaption><b>C. Switching.</b> Shows A while anything waits on you, B otherwise. Recent count added as a third dot.</figcaption></figure>
<h3>Smart Stack</h3>
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="time" style="font-size:22px;padding-top:8px">9:41</div>
<div style="margin:8px 0 0;background:#1c1c1f;border-radius:16px;padding:8px 10px"><div class="row"><span class="meta" style="--c:var(--you)">2 waiting</span><span class="r meta" style="--c:var(--work)">1 working</span></div><div class="name" style="font-size:12px;margin-top:3px">HomeLab Stats</div><div class="line mono">brew install jq</div><div class="row" style="gap:5px;margin-top:6px"><span class="big" style="flex:1;margin:0;padding:4px;background:var(--you);color:#000;font-size:11px">Allow</span><span class="big" style="flex:1;margin:0;padding:4px;background:#2c2c2e;font-size:11px">Deny</span></div></div>
</div></div><figcaption><b>Smart Stack widget.</b> The same content as the chosen face option, larger. With A, Allow and Deny work right in the stack.</figcaption></figure>
</div></body></html>
+38
View File
@@ -0,0 +1,38 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Answering from the notification</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; 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:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>A question arrives</h3>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">Which base image?</div>
<span class="big" style="background:#2c2c2e;margin-top:8px">bun:1-alpine</span><span class="big" style="background:#2c2c2e">bun:1-debian</span><span class="big" style="background:#2c2c2e">Open</span>
</div></div><figcaption><b>A. Options as buttons.</b> Each option is a button on the notification itself, then Open. A tap sends that answer.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">Which base image?</div>
<span class="big" style="background:#2c2c2e;margin-top:8px">bun:1-alpine</span><span class="big" style="background:#2c2c2e">bun:1-debian</span><span class="big" style="background:#2c2c2e">🎙 Say an answer</span>
</div></div><figcaption><b>B. Options and a spoken answer.</b> Like A, plus a dictated answer for questions that allow one. Open stays below.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">2 questions</div>
<span class="big" style="background:var(--you);color:#000;margin-top:8px">Answer</span><span class="big" style="background:#2c2c2e">Open</span>
</div></div><figcaption><b>Several questions.</b> In A and B, a request with more than one question shows Answer, which opens the app on the question screen.</figcaption></figure>
</div></body></html>