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",
|
"updatedAt": "2026-11-01T09:41:00.000Z",
|
||||||
"lastMessage": "Parser and tests are written. I want to run the suite.",
|
"lastMessage": "Parser and tests are written. I want to run the suite.",
|
||||||
"activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null,
|
"activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null,
|
||||||
|
"changes": { "files": 3, "additions": 42, "deletions": 8 } | null,
|
||||||
"request": PendingRequest | null,
|
"request": PendingRequest | null,
|
||||||
"via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | 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
|
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.
|
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 {
|
PendingRequest {
|
||||||
"id": "...",
|
"id": "...",
|
||||||
@@ -120,13 +124,15 @@ thread". For questions (`kind: "user_input"`), `options` is empty and `questions
|
|||||||
GET /v1/threads/:hostId/:threadId
|
GET /v1/threads/:hostId/:threadId
|
||||||
|
|
||||||
200 { "item": FeedItem, "messages": [Message], "canStop": true, "askFirst": false,
|
200 { "item": FeedItem, "messages": [Message], "canStop": true, "askFirst": false,
|
||||||
"earlierCursor": "w12" | null, "subagents": [ThreadRow] }
|
"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,
|
ThreadRow { "hostId": "...", "threadId": "...", "title": "Check PATH", "phase": "running" | null,
|
||||||
"settled": false, "updatedAt": "..." }
|
"settled": false, "updatedAt": "..." }
|
||||||
Message { "id": "...", "role": "assistant", "text": "...", "createdAt": "..." }
|
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.
|
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.
|
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
|
## 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
|
GET /v1/summary
|
||||||
|
|
||||||
200 { "needsYou": 1, "working": 3, "hostsOffline": 0,
|
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
|
## 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
|
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
|
`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.
|
its body starts with "Subagent <title>:", and its `hostId` and `threadId` are the subagent's.
|
||||||
|
|
||||||
| Category | When | Actions the watch registers |
|
| Category | When | Actions the watch registers |
|
||||||
|
|||||||
+10
-1
@@ -37,6 +37,7 @@ import {
|
|||||||
hostSummary,
|
hostSummary,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
projectThreads,
|
projectThreads,
|
||||||
|
searchThreads,
|
||||||
type SummaryCache,
|
type SummaryCache,
|
||||||
} from "./views.ts";
|
} from "./views.ts";
|
||||||
|
|
||||||
@@ -259,7 +260,7 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
|
|||||||
},
|
},
|
||||||
|
|
||||||
"/v1/summary": {
|
"/v1/summary": {
|
||||||
GET: authed(() => json(buildSummary(hub.list()))),
|
GET: authed(async () => json(await buildSummary(hub.list(), cache))),
|
||||||
},
|
},
|
||||||
|
|
||||||
"/v1/projects": {
|
"/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": {
|
"/v1/projects/:hostId/:projectId/threads": {
|
||||||
GET: authed<"/v1/projects/:hostId/:projectId/threads">((req) => {
|
GET: authed<"/v1/projects/:hostId/:projectId/threads">((req) => {
|
||||||
const host = findHost(req.params.hostId);
|
const host = findHost(req.params.hostId);
|
||||||
|
|||||||
+48
-1
@@ -118,6 +118,8 @@ export interface FeedItem {
|
|||||||
lastMessage: string | null;
|
lastMessage: string | null;
|
||||||
/** What a working thread is doing right now. Null when nothing is in progress or not read yet. */
|
/** What a working thread is doing right now. Null when nothing is in progress or not read yet. */
|
||||||
activity: Activity | null;
|
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;
|
request: PendingRequest | null;
|
||||||
/**
|
/**
|
||||||
* Set when `request` comes from a subagent this thread delegated to, rather than the thread
|
* 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;
|
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. */
|
/** A thread in a list: a project's threads, or a thread's subagents. */
|
||||||
export interface ThreadRow {
|
export interface ThreadRow {
|
||||||
hostId: string;
|
hostId: string;
|
||||||
@@ -162,7 +193,16 @@ export interface Summary {
|
|||||||
needsYou: number;
|
needsYou: number;
|
||||||
working: number;
|
working: number;
|
||||||
/** The thread that has waited longest for you, if any. */
|
/** 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;
|
hostsOffline: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +248,8 @@ export interface ThreadDetail {
|
|||||||
earlierCursor: string | null;
|
earlierCursor: string | null;
|
||||||
/** Subagents and delegated threads this thread started, newest first. */
|
/** Subagents and delegated threads this thread started, newest first. */
|
||||||
subagents: ThreadRow[];
|
subagents: ThreadRow[];
|
||||||
|
/** The files the latest run changed, most lines changed first. */
|
||||||
|
changedFiles: ChangedFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadModeRequest {
|
export interface ThreadModeRequest {
|
||||||
@@ -290,6 +332,11 @@ export interface PushPayload {
|
|||||||
/** Absent on `host` pushes, which are about a host rather than a thread. */
|
/** Absent on `host` pushes, which are about a host rather than a thread. */
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
requestId?: 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. */
|
/** Notification categories the watch registers actions for. */
|
||||||
|
|||||||
+19
-1
@@ -102,12 +102,30 @@ export class Notifier {
|
|||||||
body: parent ? `Subagent ${thread.title}: ${text}` : text,
|
body: parent ? `Subagent ${thread.title}: ${text}` : text,
|
||||||
category: category(phase),
|
category: category(phase),
|
||||||
fromSubagent: parent !== undefined,
|
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),
|
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 {
|
function category(phase: Phase): PushCategory {
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case "waiting_for_approval":
|
case "waiting_for_approval":
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type Command,
|
type Command,
|
||||||
type EnvironmentDescriptor,
|
type EnvironmentDescriptor,
|
||||||
type HistoryPage,
|
type HistoryPage,
|
||||||
|
type SearchMatch,
|
||||||
type LaunchThreadInput,
|
type LaunchThreadInput,
|
||||||
type Project,
|
type Project,
|
||||||
type ServerConfig,
|
type ServerConfig,
|
||||||
@@ -176,6 +177,17 @@ export class T3Host {
|
|||||||
return this.http<BoundedThread>(`/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`);
|
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`. */
|
/** One page of conversation history older than `cursor`, from `historyCursor` or a page's `nextCursor`. */
|
||||||
history(threadId: string, cursor: string): Promise<HistoryPage> {
|
history(threadId: string, cursor: string): Promise<HistoryPage> {
|
||||||
const query = new URLSearchParams({ cursor, view: "conversation" });
|
const query = new URLSearchParams({ cursor, view: "conversation" });
|
||||||
|
|||||||
+13
-1
@@ -123,8 +123,11 @@ export interface TurnItem {
|
|||||||
input?: unknown;
|
input?: unknown;
|
||||||
/** Set on `command_execution` items that are monitors rather than commands. */
|
/** Set on `command_execution` items that are monitors rather than commands. */
|
||||||
monitor?: boolean;
|
monitor?: boolean;
|
||||||
/** The file, on `file_change`. */
|
runId?: string | null;
|
||||||
|
/** The file, on `file_change`, with its line counts. */
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
|
additions?: number;
|
||||||
|
deletions?: number;
|
||||||
/** The search, on `file_search`. */
|
/** The search, on `file_search`. */
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
/** The tool, on `dynamic_tool`. */
|
/** 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. */
|
/** One page of `GET /api/orchestration/threads/:threadId/history`, older than the cursor. */
|
||||||
export interface HistoryPage {
|
export interface HistoryPage {
|
||||||
items: Array<{ item: TurnItem }>;
|
items: Array<{ item: TurnItem }>;
|
||||||
|
|||||||
+98
-3
@@ -7,6 +7,7 @@ import { resolvePhase, threadPhase, type Phase } from "./awareness.ts";
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
|
ChangedFile,
|
||||||
Feed,
|
Feed,
|
||||||
FeedItem,
|
FeedItem,
|
||||||
HostStatusDetail,
|
HostStatusDetail,
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
ThreadDetail,
|
ThreadDetail,
|
||||||
ThreadRow,
|
ThreadRow,
|
||||||
HistoryResponse,
|
HistoryResponse,
|
||||||
|
SearchResponse,
|
||||||
} from "./contract.ts";
|
} from "./contract.ts";
|
||||||
import type { T3Host } from "./t3/host.ts";
|
import type { T3Host } from "./t3/host.ts";
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +48,8 @@ export interface ThreadSummary {
|
|||||||
lastAssistantMessage: string | null;
|
lastAssistantMessage: string | null;
|
||||||
request: PendingRequest | null;
|
request: PendingRequest | null;
|
||||||
activity: Activity | null;
|
activity: Activity | null;
|
||||||
|
/** The files the latest run in the window changed. */
|
||||||
|
changedFiles: ChangedFile[];
|
||||||
/** The history route's cursor for messages before `messages`. */
|
/** The history route's cursor for messages before `messages`. */
|
||||||
earlierCursor: string | null;
|
earlierCursor: string | null;
|
||||||
}
|
}
|
||||||
@@ -70,6 +74,7 @@ export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSu
|
|||||||
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
|
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
|
||||||
request: pendingRequest(thread, turnItems),
|
request: pendingRequest(thread, turnItems),
|
||||||
activity: currentActivity(turnItems),
|
activity: currentActivity(turnItems),
|
||||||
|
changedFiles: latestChanges(turnItems),
|
||||||
earlierCursor: windowCursor(start, bounded),
|
earlierCursor: windowCursor(start, bounded),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -128,6 +133,21 @@ export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: str
|
|||||||
return { messages: [], earlierCursor: next && `h${next}` };
|
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. */
|
/** The newest step still in progress, said briefly. Monitors are left out, as they run for hours. */
|
||||||
export function currentActivity(turnItems: TurnItem[]): Activity | null {
|
export function currentActivity(turnItems: TurnItem[]): Activity | null {
|
||||||
for (let i = turnItems.length - 1; i >= 0; i--) {
|
for (let i = turnItems.length - 1; i >= 0; i--) {
|
||||||
@@ -341,6 +361,13 @@ function feedItem(
|
|||||||
updatedAt: thread.updatedAt,
|
updatedAt: thread.updatedAt,
|
||||||
lastMessage: summary?.lastAssistantMessage ?? null,
|
lastMessage: summary?.lastAssistantMessage ?? null,
|
||||||
activity: summary?.activity ?? 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,
|
request: (via ? via.summary : summary)?.request ?? null,
|
||||||
via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : 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. */
|
/** 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 needsYou = 0;
|
||||||
let working = 0;
|
let working = 0;
|
||||||
let next: { host: T3Host; thread: ThreadShell } | null = null;
|
let next: { host: T3Host; thread: ThreadShell } | null = null;
|
||||||
|
let current: { host: T3Host; thread: ThreadShell } | null = null;
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
for (const thread of host.threads.values()) {
|
for (const thread of host.threads.values()) {
|
||||||
if (thread.archivedAt || thread.deletedAt) continue;
|
if (thread.archivedAt || thread.deletedAt) continue;
|
||||||
const phase = threadPhase(thread);
|
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;
|
if (phase !== "waiting_for_approval" && phase !== "waiting_for_input") continue;
|
||||||
needsYou++;
|
needsYou++;
|
||||||
const waitingSince = thread.pendingRuntimeRequest?.createdAt ?? "";
|
const waitingSince = thread.pendingRuntimeRequest?.createdAt ?? "";
|
||||||
if (!next || waitingSince < (next.thread.pendingRuntimeRequest?.createdAt ?? "")) next = { host, thread };
|
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 {
|
return {
|
||||||
needsYou,
|
needsYou,
|
||||||
working,
|
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,
|
hostsOffline: hosts.filter((host) => host.status !== "online").length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -464,6 +521,7 @@ export async function buildThreadDetail(
|
|||||||
askFirst: thread.runtimeMode === ASK_FIRST,
|
askFirst: thread.runtimeMode === ASK_FIRST,
|
||||||
earlierCursor: summary.earlierCursor,
|
earlierCursor: summary.earlierCursor,
|
||||||
subagents: subagents(hosts, host, thread),
|
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);
|
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. */
|
/** A project's newest 50 threads, settled ones included. Subagents are listed under their parents. */
|
||||||
export function projectThreads(host: T3Host, projectId: string): ThreadRow[] {
|
export function projectThreads(host: T3Host, projectId: string): ThreadRow[] {
|
||||||
return [...host.threads.values()]
|
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 { BoundedThread, ThreadShell } from "../src/t3/types.ts";
|
||||||
import type { T3Host } from "../src/t3/host.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 => ({
|
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
||||||
id: "thread-1",
|
id: "thread-1",
|
||||||
@@ -107,10 +107,16 @@ describe("summarize", () => {
|
|||||||
describe("buildSummary", () => {
|
describe("buildSummary", () => {
|
||||||
const at = (minute: number) => `2026-11-01T09:${String(minute).padStart(2, "0")}:00.000Z`;
|
const at = (minute: number) => `2026-11-01T09:${String(minute).padStart(2, "0")}:00.000Z`;
|
||||||
const host = (label: string, status: string, threads: ThreadShell[]) =>
|
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", () => {
|
it("counts waiting and working threads and names the one waiting longest", async () => {
|
||||||
const summary = buildSummary([
|
const summary = await buildSummary([
|
||||||
host("Mac mini", "online", [
|
host("Mac mini", "online", [
|
||||||
thread({ id: "a", title: "Newer ask", pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: at(30) } }),
|
thread({ id: "a", title: "Newer ask", pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: at(30) } }),
|
||||||
thread({ id: "b", status: "running" }),
|
thread({ id: "b", status: "running" }),
|
||||||
@@ -120,11 +126,12 @@ describe("buildSummary", () => {
|
|||||||
host("LittleMan", "offline", [
|
host("LittleMan", "offline", [
|
||||||
thread({ id: "e", title: "Older ask", pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: at(5) } }),
|
thread({ id: "e", title: "Older ask", pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: at(5) } }),
|
||||||
]),
|
]),
|
||||||
]);
|
], new SummaryCache());
|
||||||
expect(summary).toEqual({
|
expect(summary).toEqual({
|
||||||
needsYou: 2,
|
needsYou: 2,
|
||||||
working: 1,
|
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,
|
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