Gateway: snooze finished threads, and name the files a file approval would change
This commit is contained in:
1 parent
d10df56cc9
commit
4dc4e57dac
8 files changed
+295
-10
No files matched your search
+34
-3
@@ -59,14 +59,16 @@ GET /v1/feed
|
||||
"working": [FeedItem],
|
||||
"recent": [FeedItem],
|
||||
"recentTotal": 12,
|
||||
"snoozedTotal": 2,
|
||||
"hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }]
|
||||
}
|
||||
```
|
||||
|
||||
`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
|
||||
settled 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
|
||||
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`. `snoozedTotal` counts finished threads snoozed for
|
||||
later, which `GET /v1/snoozed` lists. Host `status` is one of `connecting`, `online`, `offline`, `auth_invalid`, or
|
||||
`incompatible`.
|
||||
|
||||
```
|
||||
@@ -79,6 +81,7 @@ FeedItem {
|
||||
"lastMessage": "Parser and tests are written. I want to run the suite.",
|
||||
"activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null,
|
||||
"changes": { "files": 3, "additions": 42, "deletions": 8 } | null,
|
||||
"snoozedUntil": "..." | null,
|
||||
"request": PendingRequest | null,
|
||||
"via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null
|
||||
}
|
||||
@@ -110,10 +113,17 @@ PendingRequest {
|
||||
"prompt": "bun test --filter ocr",
|
||||
"reason": null,
|
||||
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
|
||||
"files": [],
|
||||
"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
|
||||
thread". For questions (`kind: "user_input"`), `options` is empty and `questions` lists
|
||||
`{ 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.
|
||||
`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.
|
||||
|
||||
## 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`) |
|
||||
| `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 |
|
||||
| `host` | A host's T3 token expires within 7 days, or was rejected | none |
|
||||
| `test` | The watch asked for a test push | none |
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
RespondRequest,
|
||||
PushResult,
|
||||
SettleRecentResponse,
|
||||
SnoozeRequest,
|
||||
SendMessageRequest,
|
||||
StatusResponse,
|
||||
ThreadListResponse,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
ASK_FIRST,
|
||||
buildFeed,
|
||||
buildRecent,
|
||||
buildSnoozed,
|
||||
buildSummary,
|
||||
buildThreadDetail,
|
||||
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 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": {
|
||||
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": {
|
||||
POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => {
|
||||
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
|
||||
|
||||
@@ -93,6 +93,11 @@ export interface PendingRequest {
|
||||
reason: string | null;
|
||||
/** Approval choices, in the server's order. Empty for questions. `warning` flags a risky choice. */
|
||||
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: Array<{
|
||||
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 {
|
||||
hostId: string;
|
||||
hostLabel: string;
|
||||
@@ -120,6 +134,8 @@ export interface FeedItem {
|
||||
activity: Activity | null;
|
||||
/** What the latest run changed, in total. Null when it changed no files or is not read yet. */
|
||||
changes: ChangeTotals | null;
|
||||
/** When a snoozed thread comes back to Recent. Null when it is not snoozed. */
|
||||
snoozedUntil: string | null;
|
||||
request: PendingRequest | null;
|
||||
/**
|
||||
* Set when `request` comes from a subagent this thread delegated to, rather than the thread
|
||||
@@ -215,6 +231,8 @@ export interface Feed {
|
||||
recent: FeedItem[];
|
||||
/** How many threads Recent holds in all. More than `recent.length` means `GET /v1/recent` has the rest. */
|
||||
recentTotal: number;
|
||||
/** Finished threads snoozed for later, listed by `GET /v1/snoozed`. */
|
||||
snoozedTotal: number;
|
||||
hosts: HostSummary[];
|
||||
}
|
||||
|
||||
@@ -252,6 +270,11 @@ export interface ThreadDetail {
|
||||
changedFiles: ChangedFile[];
|
||||
}
|
||||
|
||||
export interface SnoozeRequest {
|
||||
/** When the thread comes back, in the future and at most 30 days away. */
|
||||
until: string;
|
||||
}
|
||||
|
||||
export interface ThreadModeRequest {
|
||||
askFirst: boolean;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface ThreadShell {
|
||||
/** Set on threads an agent started with delegate_task, which T3 does not mark as subagents. */
|
||||
delegatedFrom?: { environmentId: string; threadId: string; title: string };
|
||||
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 {
|
||||
@@ -128,6 +130,8 @@ export interface TurnItem {
|
||||
fileName?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
/** A unified diff, on `file_change` once the edit has run. */
|
||||
diffStr?: string;
|
||||
/** The search, on `file_search`. */
|
||||
pattern?: string;
|
||||
/** The tool, on `dynamic_tool`. */
|
||||
@@ -235,6 +239,8 @@ export type Command =
|
||||
}
|
||||
| { type: "thread.stop"; 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: "run.interrupt"; commandId: string; threadId: string; runId: string };
|
||||
|
||||
|
||||
+63
-6
@@ -8,6 +8,7 @@ import type {
|
||||
Activity,
|
||||
AgentProvider,
|
||||
ChangedFile,
|
||||
PendingFile,
|
||||
Feed,
|
||||
FeedItem,
|
||||
HostStatusDetail,
|
||||
@@ -185,7 +186,7 @@ function describeStep(item: TurnItem): Omit<Activity, "startedAt"> | null {
|
||||
function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projection"]["turnItems"]): PendingRequest | null {
|
||||
const pending = thread.pendingRuntimeRequest;
|
||||
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(
|
||||
(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: "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)) {
|
||||
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. */
|
||||
export class SummaryCache {
|
||||
private readonly entries = new Map<string, { updatedAt: string; fetchedAt: number; summary: ThreadSummary }>();
|
||||
@@ -361,6 +402,7 @@ function feedItem(
|
||||
updatedAt: thread.updatedAt,
|
||||
lastMessage: summary?.lastAssistantMessage ?? null,
|
||||
activity: summary?.activity ?? null,
|
||||
snoozedUntil: isSnoozed(thread, Date.now()) ? thread.snoozedUntil! : null,
|
||||
changes: 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. */
|
||||
export function feedCandidates(hosts: T3Host[]) {
|
||||
export function feedCandidates(hosts: T3Host[], now = Date.now()) {
|
||||
const found: Candidate[] = [];
|
||||
for (const host of hosts) {
|
||||
for (const thread of host.threads.values()) {
|
||||
@@ -457,13 +503,18 @@ export function feedCandidates(hosts: T3Host[]) {
|
||||
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
|
||||
b.thread.updatedAt.localeCompare(a.thread.updatedAt);
|
||||
const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
|
||||
const finished = candidates.filter(({ phase }) => phase === "completed" || phase === "failed");
|
||||
return {
|
||||
needsYou: candidates
|
||||
.filter(({ phase }) => isWaiting(phase))
|
||||
.sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))),
|
||||
working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest),
|
||||
/** Finished and failed threads not yet settled, however old. */
|
||||
recent: candidates.filter(({ phase }) => phase === "completed" || phase === "failed").sort(byNewest),
|
||||
/** Finished and failed threads not yet settled or snoozed, however old. */
|
||||
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. */
|
||||
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 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)),
|
||||
recent: recent.map(toItem(cache)),
|
||||
recentTotal: allRecent.length,
|
||||
snoozedTotal: snoozed.length,
|
||||
hosts: hosts.map(hostSummary),
|
||||
};
|
||||
}
|
||||
@@ -500,6 +552,11 @@ export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentRespons
|
||||
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
|
||||
* has no request of its own but a subagent it delegated to is waiting, shows that request.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
|
||||
|
||||
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
|
||||
import type { T3Host } from "../src/t3/host.ts";
|
||||
import { agentProviders, buildFeed, currentActivity, 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 => ({
|
||||
id: "thread-1",
|
||||
@@ -73,6 +73,7 @@ describe("summarize", () => {
|
||||
{ decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" },
|
||||
{ decision: "decline", label: "Deny" },
|
||||
],
|
||||
files: [],
|
||||
questions: [],
|
||||
});
|
||||
});
|
||||
@@ -343,3 +344,48 @@ describe("searchThreads", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user