Gateway: snooze finished threads, and name the files a file approval would change
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

This commit is contained in:
gib committed 2026-11-02 14:20:39 -05:00
1 parent d10df56cc9
commit 4dc4e57dac
8 files changed
+295 -10

No files matched your search

+34 -3
View File
@@ -59,14 +59,16 @@ GET /v1/feed
"working": [FeedItem], "working": [FeedItem],
"recent": [FeedItem], "recent": [FeedItem],
"recentTotal": 12, "recentTotal": 12,
"snoozedTotal": 2,
"hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }] "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }]
} }
``` ```
`needsYou` holds threads waiting for an approval or an answer, oldest first. `working` holds running `needsYou` holds threads waiting for an approval or an answer, oldest first. `working` holds running
threads, most recently updated first. `recent` holds threads that completed or failed and are not threads, most recently updated first. `recent` holds threads that completed or failed and are not
settled yet, however old, newest first and at most 10. `recentTotal` counts all of them. When it is settled or snoozed yet, however old, newest first and at most 10. `recentTotal` counts all of them. When it is
larger, the rest come from `GET /v1/recent`. Host `status` is one of `connecting`, `online`, `offline`, `auth_invalid`, or larger, the rest come from `GET /v1/recent`. `snoozedTotal` counts finished threads snoozed for
later, which `GET /v1/snoozed` lists. Host `status` is one of `connecting`, `online`, `offline`, `auth_invalid`, or
`incompatible`. `incompatible`.
``` ```
@@ -79,6 +81,7 @@ FeedItem {
"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, "changes": { "files": 3, "additions": 42, "deletions": 8 } | null,
"snoozedUntil": "..." | null,
"request": PendingRequest | null, "request": PendingRequest | null,
"via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null "via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null
} }
@@ -110,10 +113,17 @@ PendingRequest {
"prompt": "bun test --filter ocr", "prompt": "bun test --filter ocr",
"reason": null, "reason": null,
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }], "options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
"files": [],
"questions": [] "questions": []
} }
``` ```
For `kind: "file-change"`, `files` lists the files the agent wants to change as
`{ path, name, additions, deletions, preview }`. `name` is the last part of `path`. Most providers,
Claude included, send the diff only after the edit runs, so `additions` and `deletions` are usually
null and `preview` is empty. When a diff comes with the request, `preview` holds its first 4
changed lines, each starting with `+` or `-`.
An option may also carry `warning`, a short note about a risky choice such as "Applies to every An option may also carry `warning`, a short note about a risky choice such as "Applies to every
thread". For questions (`kind: "user_input"`), `options` is empty and `questions` lists thread". For questions (`kind: "user_input"`), `options` is empty and `questions` lists
`{ id, header, question, options: [{ label, value }], allowCustomAnswer, multiSelect }`. `{ id, header, question, options: [{ label, value }], allowCustomAnswer, multiSelect }`.
@@ -205,6 +215,27 @@ POST /v1/recent/settle
Settles every completed thread in Recent, including those past the first 10. Failed threads stay. Settles every completed thread in Recent, including those past the first 10. Failed threads stay.
`failed` counts threads T3 refused or whose host was offline. `failed` counts threads T3 refused or whose host was offline.
```
GET /v1/snoozed
200 { "items": [FeedItem] }
```
Finished threads snoozed for later, the soonest to come back first, with `snoozedUntil` set. Like
`GET /v1/recent`, it uses only cached summaries.
```
POST /v1/threads/:hostId/:threadId/snooze
{ "until": "2026-11-03T14:00:00.000Z" }
202
POST /v1/threads/:hostId/:threadId/unsnooze
202
```
Snoozing hides a finished thread from Recent until `until`, which must be in the next 30 days. Then
it comes back on its own. A snoozed thread that starts working or waiting on you shows as usual.
Thread detail fills `request` and `via` the same way as the feed when a subagent waits on you. Thread detail fills `request` and `via` the same way as the feed when a subagent waits on you.
## Summary ## Summary
@@ -294,7 +325,7 @@ its body starts with "Subagent <title>:", and its `hostId` and `threadId` are th
| --- | --- | --- | | --- | --- | --- |
| `approval` | A thread needs an approval | Allow (`accept`), Deny (`decline`) | | `approval` | A thread needs an approval | Allow (`accept`), Deny (`decline`) |
| `input` | A thread asked a question | Answer, Open. With `question`: one button per option, Say an answer (text input) when `allowCustomAnswer`, Open | | `input` | A thread asked a question | Answer, Open. With `question`: one button per option, Say an answer (text input) when `allowCustomAnswer`, Open |
| `finished` | A turn completed | Reply (text input), Open | | `finished` | A turn completed | Reply (text input), Settle, Open |
| `failed` | A turn failed | Open | | `failed` | A turn failed | Open |
| `host` | A host's T3 token expires within 7 days, or was rejected | none | | `host` | A host's T3 token expires within 7 days, or was rejected | none |
| `test` | The watch asked for a test push | none | | `test` | The watch asked for a test push | none |
+33
View File
@@ -14,6 +14,7 @@ import type {
RespondRequest, RespondRequest,
PushResult, PushResult,
SettleRecentResponse, SettleRecentResponse,
SnoozeRequest,
SendMessageRequest, SendMessageRequest,
StatusResponse, StatusResponse,
ThreadListResponse, ThreadListResponse,
@@ -30,6 +31,7 @@ import {
ASK_FIRST, ASK_FIRST,
buildFeed, buildFeed,
buildRecent, buildRecent,
buildSnoozed,
buildSummary, buildSummary,
buildThreadDetail, buildThreadDetail,
feedCandidates, feedCandidates,
@@ -71,6 +73,8 @@ class HttpError extends Error {
} }
} }
const SNOOZE_MAX_MS = 30 * 24 * 60 * 60_000;
const json = (body: unknown, status = 200) => Response.json(body, { status }); const json = (body: unknown, status = 200) => Response.json(body, { status });
const accepted = () => new Response(null, { status: 202 }); const accepted = () => new Response(null, { status: 202 });
@@ -259,6 +263,10 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
}), }),
}, },
"/v1/snoozed": {
GET: authed(() => json(buildSnoozed(hub.list(), cache))),
},
"/v1/summary": { "/v1/summary": {
GET: authed(async () => json(await buildSummary(hub.list(), cache))), GET: authed(async () => json(await buildSummary(hub.list(), cache))),
}, },
@@ -401,6 +409,31 @@ export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {}
}), }),
}, },
"/v1/threads/:hostId/:threadId/snooze": {
POST: authed<"/v1/threads/:hostId/:threadId/snooze">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
const until = Date.parse(String((await readBody<SnoozeRequest>(req)).until ?? ""));
if (!(until > Date.now() && until - Date.now() <= SNOOZE_MAX_MS)) {
throw new HttpError(400, "Snooze until a time in the next 30 days.");
}
await host.dispatch({
type: "thread.snooze",
commandId: crypto.randomUUID(),
threadId: thread.id,
snoozedUntil: new Date(until).toISOString(),
});
return accepted();
}),
},
"/v1/threads/:hostId/:threadId/unsnooze": {
POST: authed<"/v1/threads/:hostId/:threadId/unsnooze">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
await host.dispatch({ type: "thread.unsnooze", commandId: crypto.randomUUID(), threadId: thread.id, reason: "user" });
return accepted();
}),
},
"/v1/threads/:hostId/:threadId/stop": { "/v1/threads/:hostId/:threadId/stop": {
POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => { POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId); const { host, thread } = findThread(req.params.hostId, req.params.threadId);
+23
View File
@@ -93,6 +93,11 @@ export interface PendingRequest {
reason: string | null; reason: string | null;
/** Approval choices, in the server's order. Empty for questions. `warning` flags a risky choice. */ /** Approval choices, in the server's order. Empty for questions. `warning` flags a risky choice. */
options: Array<{ decision: ApprovalDecision; label: string; warning?: string }>; options: Array<{ decision: ApprovalDecision; label: string; warning?: string }>;
/**
* On `file-change` approvals: the files the agent wants to change. Counts and preview are null
* and empty when the server sends no diff before approval, which is the usual case.
*/
files: PendingFile[];
/** Questions for the user. Empty for approvals. */ /** Questions for the user. Empty for approvals. */
questions: Array<{ questions: Array<{
id: string; id: string;
@@ -105,6 +110,15 @@ export interface PendingRequest {
}>; }>;
} }
export interface PendingFile {
path: string;
name: string;
additions: number | null;
deletions: number | null;
/** The first few changed lines, each starting with `+` or `-`. */
preview: string[];
}
export interface FeedItem { export interface FeedItem {
hostId: string; hostId: string;
hostLabel: string; hostLabel: string;
@@ -120,6 +134,8 @@ export interface FeedItem {
activity: Activity | null; activity: Activity | null;
/** What the latest run changed, in total. Null when it changed no files or is not read yet. */ /** What the latest run changed, in total. Null when it changed no files or is not read yet. */
changes: ChangeTotals | null; changes: ChangeTotals | null;
/** When a snoozed thread comes back to Recent. Null when it is not snoozed. */
snoozedUntil: string | 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
@@ -215,6 +231,8 @@ export interface Feed {
recent: FeedItem[]; recent: FeedItem[];
/** How many threads Recent holds in all. More than `recent.length` means `GET /v1/recent` has the rest. */ /** How many threads Recent holds in all. More than `recent.length` means `GET /v1/recent` has the rest. */
recentTotal: number; recentTotal: number;
/** Finished threads snoozed for later, listed by `GET /v1/snoozed`. */
snoozedTotal: number;
hosts: HostSummary[]; hosts: HostSummary[];
} }
@@ -252,6 +270,11 @@ export interface ThreadDetail {
changedFiles: ChangedFile[]; changedFiles: ChangedFile[];
} }
export interface SnoozeRequest {
/** When the thread comes back, in the future and at most 30 days away. */
until: string;
}
export interface ThreadModeRequest { export interface ThreadModeRequest {
askFirst: boolean; askFirst: boolean;
} }
+6
View File
@@ -72,6 +72,8 @@ export interface ThreadShell {
/** Set on threads an agent started with delegate_task, which T3 does not mark as subagents. */ /** Set on threads an agent started with delegate_task, which T3 does not mark as subagents. */
delegatedFrom?: { environmentId: string; threadId: string; title: string }; delegatedFrom?: { environmentId: string; threadId: string; title: string };
settledOverride?: Open<"settled"> | null; settledOverride?: Open<"settled"> | null;
/** Set while the user has snoozed the thread. It stays set after the time passes. */
snoozedUntil?: string | null;
} }
export interface ShellSnapshot { export interface ShellSnapshot {
@@ -128,6 +130,8 @@ export interface TurnItem {
fileName?: string; fileName?: string;
additions?: number; additions?: number;
deletions?: number; deletions?: number;
/** A unified diff, on `file_change` once the edit has run. */
diffStr?: string;
/** The search, on `file_search`. */ /** The search, on `file_search`. */
pattern?: string; pattern?: string;
/** The tool, on `dynamic_tool`. */ /** The tool, on `dynamic_tool`. */
@@ -235,6 +239,8 @@ export type Command =
} }
| { type: "thread.stop"; commandId: string; threadId: string } | { type: "thread.stop"; commandId: string; threadId: string }
| { type: "thread.settle"; commandId: string; threadId: string } | { type: "thread.settle"; commandId: string; threadId: string }
| { type: "thread.snooze"; commandId: string; threadId: string; snoozedUntil: string }
| { type: "thread.unsnooze"; commandId: string; threadId: string; reason: "user" }
| { type: "thread.runtime-mode.set"; commandId: string; threadId: string; runtimeMode: string } | { type: "thread.runtime-mode.set"; commandId: string; threadId: string; runtimeMode: string }
| { type: "run.interrupt"; commandId: string; threadId: string; runId: string }; | { type: "run.interrupt"; commandId: string; threadId: string; runId: string };
+63 -6
View File
@@ -8,6 +8,7 @@ import type {
Activity, Activity,
AgentProvider, AgentProvider,
ChangedFile, ChangedFile,
PendingFile,
Feed, Feed,
FeedItem, FeedItem,
HostStatusDetail, HostStatusDetail,
@@ -185,7 +186,7 @@ function describeStep(item: TurnItem): Omit<Activity, "startedAt"> | null {
function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projection"]["turnItems"]): PendingRequest | null { function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projection"]["turnItems"]): PendingRequest | null {
const pending = thread.pendingRuntimeRequest; const pending = thread.pendingRuntimeRequest;
if (!pending || pending.kind === "auth_refresh") return null; if (!pending || pending.kind === "auth_refresh") return null;
const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], questions: [] }; const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], files: [], questions: [] };
const item = turnItems.findLast( const item = turnItems.findLast(
(candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id, (candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id,
); );
@@ -196,7 +197,9 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio
{ decision: "accept" as const, label: "Allow" }, { decision: "accept" as const, label: "Allow" },
{ decision: "decline" as const, label: "Deny" }, { decision: "decline" as const, label: "Deny" },
]; ];
return { ...base, prompt: item.prompt ?? item.title ?? null, reason: item.reason ?? null, options }; const prompt = item.prompt ?? item.title ?? null;
const files = pending.kind === "file-change" ? pendingFiles(turnItems, item.runId, prompt) : [];
return { ...base, prompt, reason: item.reason ?? null, options, files };
} }
if (item && isUserInputRequest(item)) { if (item && isUserInputRequest(item)) {
return { return {
@@ -223,6 +226,44 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio
}; };
} }
const PREVIEW_LINES = 4;
const PREVIEW_CHARS = 60;
/**
* The files a file-change approval would touch: the run's unfinished file changes, or failing
* those, the path in the prompt, which Claude words as `Edit: <path>`. Most providers send the
* diff only after the edit runs, so counts and preview are usually empty.
*/
export function pendingFiles(turnItems: TurnItem[], runId: string | null | undefined, prompt: string | null): PendingFile[] {
const files = turnItems
.filter(
(item) =>
item.type === "file_change" &&
item.fileName &&
(!runId || item.runId === runId) &&
item.status !== "completed" &&
item.status !== "failed" &&
item.status !== "cancelled",
)
.map((item) => {
const preview = (item.diffStr ?? "")
.split("\n")
.filter((line) => /^[+-]/.test(line) && !/^(\+\+\+|---)( |$)/.test(line))
.slice(0, PREVIEW_LINES)
.map((line) => (line.length > PREVIEW_CHARS ? `${line.slice(0, PREVIEW_CHARS)}…` : line));
return {
path: item.fileName!,
name: item.fileName!.split("/").pop()!,
additions: item.additions ?? null,
deletions: item.deletions ?? null,
preview,
};
});
if (files.length > 0) return files;
const path = prompt?.match(/^\w+: (\S.*)$/)?.[1]?.trim();
return path ? [{ path, name: path.split("/").pop()!, additions: null, deletions: null, preview: [] }] : [];
}
/** Per-thread summaries, refetched when a thread's `updatedAt` changes. */ /** Per-thread summaries, refetched when a thread's `updatedAt` changes. */
export class SummaryCache { export class SummaryCache {
private readonly entries = new Map<string, { updatedAt: string; fetchedAt: number; summary: ThreadSummary }>(); private readonly entries = new Map<string, { updatedAt: string; fetchedAt: number; summary: ThreadSummary }>();
@@ -361,6 +402,7 @@ function feedItem(
updatedAt: thread.updatedAt, updatedAt: thread.updatedAt,
lastMessage: summary?.lastAssistantMessage ?? null, lastMessage: summary?.lastAssistantMessage ?? null,
activity: summary?.activity ?? null, activity: summary?.activity ?? null,
snoozedUntil: isSnoozed(thread, Date.now()) ? thread.snoozedUntil! : null,
changes: summary?.changedFiles.length changes: summary?.changedFiles.length
? { ? {
files: summary.changedFiles.length, files: summary.changedFiles.length,
@@ -442,8 +484,12 @@ export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promis
}; };
} }
/** True while a snooze is set and its time has not come. */
const isSnoozed = (thread: ThreadShell, now: number) =>
!!thread.snoozedUntil && Date.parse(thread.snoozedUntil) > now;
/** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */ /** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */
export function feedCandidates(hosts: T3Host[]) { export function feedCandidates(hosts: T3Host[], now = Date.now()) {
const found: Candidate[] = []; const found: Candidate[] = [];
for (const host of hosts) { for (const host of hosts) {
for (const thread of host.threads.values()) { for (const thread of host.threads.values()) {
@@ -457,13 +503,18 @@ export function feedCandidates(hosts: T3Host[]) {
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) => const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
b.thread.updatedAt.localeCompare(a.thread.updatedAt); b.thread.updatedAt.localeCompare(a.thread.updatedAt);
const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread; const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
const finished = candidates.filter(({ phase }) => phase === "completed" || phase === "failed");
return { return {
needsYou: candidates needsYou: candidates
.filter(({ phase }) => isWaiting(phase)) .filter(({ phase }) => isWaiting(phase))
.sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))), .sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))),
working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest), working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest),
/** Finished and failed threads not yet settled, however old. */ /** Finished and failed threads not yet settled or snoozed, however old. */
recent: candidates.filter(({ phase }) => phase === "completed" || phase === "failed").sort(byNewest), recent: finished.filter(({ thread }) => !isSnoozed(thread, now)).sort(byNewest),
/** Finished threads snoozed for later, the soonest to come back first. */
snoozed: finished
.filter(({ thread }) => isSnoozed(thread, now))
.sort((a, b) => a.thread.snoozedUntil!.localeCompare(b.thread.snoozedUntil!)),
}; };
} }
@@ -474,7 +525,7 @@ const toItem =
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */ /** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<Feed> { export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<Feed> {
const { needsYou, working, recent: allRecent } = feedCandidates(hosts); const { needsYou, working, recent: allRecent, snoozed } = feedCandidates(hosts);
const recent = allRecent.slice(0, RECENT_LIMIT); const recent = allRecent.slice(0, RECENT_LIMIT);
const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate])); const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate]));
@@ -488,6 +539,7 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<F
working: working.map(toItem(cache)), working: working.map(toItem(cache)),
recent: recent.map(toItem(cache)), recent: recent.map(toItem(cache)),
recentTotal: allRecent.length, recentTotal: allRecent.length,
snoozedTotal: snoozed.length,
hosts: hosts.map(hostSummary), hosts: hosts.map(hostSummary),
}; };
} }
@@ -500,6 +552,11 @@ export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentRespons
return { items: feedCandidates(hosts).recent.map(toItem(cache)) }; return { items: feedCandidates(hosts).recent.map(toItem(cache)) };
} }
/** Snoozed finished threads, the soonest to come back first. Cached summaries only, like Recent. */
export function buildSnoozed(hosts: T3Host[], cache: SummaryCache): RecentResponse {
return { items: feedCandidates(hosts).snoozed.map(toItem(cache)) };
}
/** /**
* One thread with its recent messages. Fetches the summary if it is not cached. When the thread * One thread with its recent messages. Fetches the summary if it is not cached. When the thread
* has no request of its own but a subagent it delegated to is waiting, shows that request. * has no request of its own but a subagent it delegated to is waiting, shows that request.
+47 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts"; import type { 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, latestChanges, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts"; import { agentProviders, buildFeed, currentActivity, feedCandidates, latestChanges, pendingFiles, 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",
@@ -73,6 +73,7 @@ describe("summarize", () => {
{ decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" }, { decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" },
{ decision: "decline", label: "Deny" }, { decision: "decline", label: "Deny" },
], ],
files: [],
questions: [], questions: [],
}); });
}); });
@@ -343,3 +344,48 @@ describe("searchThreads", () => {
expect(result.unsearched).toEqual(["Laptop"]); expect(result.unsearched).toEqual(["Laptop"]);
}); });
}); });
describe("pendingFiles", () => {
it("lists the run's unfinished file changes with whatever counts and diff the server sent", () => {
const files = pendingFiles(
[
{ type: "file_change", runId: "run-2", status: "completed", fileName: "done.ts" },
{ type: "file_change", runId: "run-2", status: "pending", fileName: "deploy/compose.yml", additions: 2, deletions: 1, diffStr: "--- a/compose.yml\n+++ b/compose.yml\n@@ -1 +1,2 @@\n- image: bun:1\n+ image: bun:1-alpine\n+ restart: always" },
],
"run-2",
"Edit: deploy/compose.yml",
);
expect(files).toEqual([
{ path: "deploy/compose.yml", name: "compose.yml", additions: 2, deletions: 1, preview: ["- image: bun:1", "+ image: bun:1-alpine", "+ restart: always"] },
]);
});
it("falls back to the path in Claude's prompt when no change is in the window", () => {
expect(pendingFiles([], "run-1", "Edit: /home/me/app/src/main.ts")).toEqual([
{ path: "/home/me/app/src/main.ts", name: "main.ts", additions: null, deletions: null, preview: [] },
]);
});
});
describe("snoozed threads", () => {
it("leave Recent until their time comes, the soonest first", () => {
const now = Date.parse("2026-11-01T12:00:00.000Z");
const host = {
id: "Desktop",
label: "Desktop",
threads: new Map(
[
thread({ id: "done", status: "completed" }),
thread({ id: "later", status: "completed", snoozedUntil: "2026-11-02T09:00:00.000Z" }),
thread({ id: "soon", status: "completed", snoozedUntil: "2026-11-01T13:00:00.000Z" }),
thread({ id: "woke", status: "completed", snoozedUntil: "2026-11-01T11:00:00.000Z" }),
thread({ id: "busy", status: "running", snoozedUntil: "2026-11-02T09:00:00.000Z" }),
].map((t) => [t.id, t]),
),
} as unknown as T3Host;
const { recent, snoozed, working } = feedCandidates([host], now);
expect(recent.map(({ thread }) => thread.id).sort()).toEqual(["done", "woke"]);
expect(snoozed.map(({ thread }) => thread.id)).toEqual(["soon", "later"]);
expect(working.map(({ thread }) => thread.id)).toEqual(["busy"]);
});
});
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 5: Snooze</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; --snz:#9b8cff; }
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; }
.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; }
.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); }
.acts { display:flex; gap:5px; margin-top:6px; } .acts span { flex:1; text-align:center; border-radius:13px; padding:5px 0; font-size:12px; font-weight:700; }
.swipe { display:flex; margin-bottom:5px; } .swipe .card { flex:1; margin:0; border-radius:15px 0 0 15px; }
.act { width:44px; display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:9.5px; font-weight:700; color:#000; }
</style></head><body><div class="grid">
<h3>Option 1: a second swipe action beside Settle</h3>
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
<div class="swipe"><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><div class="act" style="background:var(--snz)">☾<br>Snooze</div><div class="act" style="background:var(--work);border-radius:0 15px 15px 0">✓<br>Settle</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>Swipe left.</b> Snooze sits beside Settle, in violet.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">Snooze until</span><span>9:41</span></div>
<span class="big" style="background:var(--card)">In 1 hour</span><span class="big" style="background:var(--card)">This evening · 6 PM</span><span class="big" style="background:var(--card)">Tomorrow · 9 AM</span><span class="big" style="background:var(--card)">Next week</span>
</div></div><figcaption><b>Pick a time.</b> Four fixed choices. The card leaves Recent and comes back on its own when the time is up.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span></span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · 1h ago</div><div class="name" style="color:var(--dim)">Explain the hub</div></div>
<div class="card" style="--c:var(--snz);opacity:.85"><div class="row"><span class="name" style="font-size:12px;color:var(--snz)">☾ 2 snoozed</span><span class="r" style="color:var(--snz)">›</span></div></div>
</div></div><figcaption><b>Snoozed row.</b> Under Recent, a dim row counts snoozed threads and opens their list, each with when it wakes and Unsnooze.</figcaption></figure>
<h3>Option 2: snooze from the 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>
<span class="big" style="background:var(--card)">Reply</span>
<div class="row" style="gap:5px"><span class="big" style="flex:1;background:var(--work);color:#000;margin:0">Settle</span><span class="big" style="flex:1;background:#2c2c2e;color:var(--snz);margin:0">Snooze</span></div>
</div></div><figcaption><b>Beside Settle.</b> Cards keep their one swipe action. Snooze is a button next to Settle on the thread screen, with the same time picker.</figcaption></figure>
</div></body></html>
+47
View File
@@ -0,0 +1,47 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 5: File approvals</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; --snz:#9b8cff; }
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; }
.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; }
.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); }
.acts { display:flex; gap:5px; margin-top:6px; } .acts span { flex:1; text-align:center; border-radius:13px; padding:5px 0; font-size:12px; font-weight:700; }
.swipe { display:flex; margin-bottom:5px; } .swipe .card { flex:1; margin:0; border-radius:15px 0 0 15px; }
.act { width:44px; display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:9.5px; font-weight:700; color:#000; }
</style></head><body><div class="grid">
<h3>Option 1: file and line counts on the card</h3>
<figure><div class="bezel"><div class="screen"><div class="top"><span>2</span><span>9:41</span></div>
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Mac mini · wristcode</div><div class="name">Gateway Containerfile</div>
<div class="line">Wants to edit</div><div class="row mono" style="margin-top:2px"><span style="color:#fff">compose.yml</span><span class="r"><span class="add">+9</span> <span class="del">−6</span></span></div>
<div class="acts"><span style="background:var(--you);color:#000">Allow</span><span style="background:#2c2c2e">Deny</span></div></div>
</div></div><figcaption><b>Feed card.</b> "Wants to edit", then the file name and how many lines it adds and removes. Same on the thread screen and the notification.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Wants to edit</div><div class="row mono"><span style="color:#fff">compose.yml</span><span class="r"><span class="add">+9</span> <span class="del">−6</span></span></div>
<div class="mono" style="margin-top:5px;line-height:1.35"><div class="del">− image: bun:1</div><div class="add">+ image: bun:1-alpine</div><div class="add">+ restart: always</div></div></div>
<div class="acts" style="margin:0 2px"><span style="background:var(--you);color:#000">Allow</span><span style="background:#2c2c2e">Deny</span></div>
</div></div><figcaption><b>Thread screen.</b> The same, plus the first few changed lines, red and green. Enough to catch a wrong file or a wild edit.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Wants to edit 3 files</div>
<div class="row mono"><span style="color:#fff">compose.yml</span><span class="r"><span class="add">+9</span> <span class="del">−6</span></span></div>
<div class="row mono"><span style="color:#fff">Containerfile</span><span class="r"><span class="add">+31</span> <span class="del">−0</span></span></div>
<div class="row mono"><span style="color:#fff">health.ts</span><span class="r"><span class="add">+2</span> <span class="del">−2</span></span></div></div>
</div></div><figcaption><b>Several files.</b> One row each, with no line preview.</figcaption></figure>
<h3>Option 2: file name only</h3>
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span>2</span><span>9:41</span></div>
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Mac mini · wristcode</div><div class="name">Gateway Containerfile</div>
<div class="line">Wants to edit <span class="mono" style="color:#fff">compose.yml</span></div>
<div class="acts"><span style="background:var(--you);color:#000">Allow</span><span style="background:#2c2c2e">Deny</span></div></div>
</div></div><figcaption><b>Just the name.</b> No counts and no preview. Simplest, and it works even when T3 sends no diff.</figcaption></figure>
</div></body></html>