Gateway: changed files, search across hosts, a richer complication summary, and question options in pushes
This commit is contained in:
1 parent
45251b1a43
commit
b08fd9bdb1
13 files changed
+500
-19
No files matched your search
+35
-6
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
@@ -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()]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user